├── app ├── logo │ ├── app-logo.png │ └── app-logo-ios.png ├── src │ ├── commonMain │ │ ├── kotlin │ │ │ └── io │ │ │ │ └── sellmair │ │ │ │ └── pacemaker │ │ │ │ ├── ui │ │ │ │ ├── Intent.kt │ │ │ │ ├── Page.kt │ │ │ │ ├── logging.kt │ │ │ │ ├── BackHandlerIfAny.kt │ │ │ │ ├── mainPage │ │ │ │ │ ├── MainPage.kt │ │ │ │ │ ├── GroupHeartRateScale.kt │ │ │ │ │ ├── UtteranceControlButton.kt │ │ │ │ │ └── MyStatusHeader.kt │ │ │ │ ├── colorUtils.kt │ │ │ │ ├── widget │ │ │ │ │ ├── OnHeartRateScaleSpring.kt │ │ │ │ │ ├── GradientBackdrop.kt │ │ │ │ │ ├── Headline.kt │ │ │ │ │ ├── ColorHueSlider.kt │ │ │ │ │ ├── coroutineUtils.kt │ │ │ │ │ ├── OnHeartRateScalePosition.kt │ │ │ │ │ ├── ExperimentalFeatureToggle.kt │ │ │ │ │ ├── MemberHeartRateLimit.kt │ │ │ │ │ ├── UserHead.kt │ │ │ │ │ ├── MemberHeartRateIndicator.kt │ │ │ │ │ └── HeartRateScale.kt │ │ │ │ ├── settingsPage │ │ │ │ │ ├── SettingsPage.kt │ │ │ │ │ └── SettingsPageHeader.kt │ │ │ │ ├── ApplicationWindow.kt │ │ │ │ ├── launchHeartRateUtteranceActor.kt │ │ │ │ ├── PacemakerTheme.kt │ │ │ │ └── timelinePage │ │ │ │ │ └── TimelinePage.kt │ │ │ │ └── launchFrontendServices.kt │ │ └── composeResources │ │ │ ├── values │ │ │ └── strings.xml │ │ │ ├── values-de │ │ │ └── strings.xml │ │ │ └── values-it │ │ │ └── strings.xml │ ├── androidMain │ │ ├── play_store_512.png │ │ ├── res │ │ │ ├── values │ │ │ │ └── styles.xml │ │ │ ├── xml │ │ │ │ └── locales_config.xml │ │ │ └── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher_round.xml │ │ │ │ └── ic_launcher.xml │ │ └── kotlin │ │ │ ├── AndroidBackHandler.kt │ │ │ └── Previews.kt │ ├── jvmMain │ │ └── kotlin │ │ │ └── io │ │ │ └── sellmair │ │ │ └── pacemaker │ │ │ └── ui │ │ │ ├── BackHandlerIfAny.jvm.kt │ │ │ └── main.kt │ ├── appleMain │ │ └── kotlin │ │ │ └── BackHandlerIfAny.apple.kt │ └── iosMain │ │ └── kotlin │ │ ├── backend.kt │ │ └── IosPacemakerViewController.kt └── build.gradle.kts ├── app-core ├── src │ ├── commonMain │ │ └── kotlin │ │ │ └── io │ │ │ └── sellmair │ │ │ └── pacemaker │ │ │ ├── Settings.kt │ │ │ ├── SqlDdriver.kt │ │ │ ├── logging.kt │ │ │ ├── UtteranceEvent.kt │ │ │ ├── UserState.kt │ │ │ ├── SessionRecord.kt │ │ │ ├── meId.kt │ │ │ ├── withHeartRateSensor.kt │ │ │ ├── UtteranceState.kt │ │ │ ├── CriticalGroupState.kt │ │ │ ├── launchUtteranceSettingsActor.kt │ │ │ ├── launchHeartRateSensorMeasurement.kt │ │ │ ├── launchPacemakerBroadcastReceiver.kt │ │ │ ├── launchUpdateMeActor.kt │ │ │ ├── launchHeartRateSensorLinkingActor.kt │ │ │ ├── SqlActiveSessionService.kt │ │ │ ├── SessionsState.kt │ │ │ ├── colorUtils.kt │ │ │ ├── UserService.kt │ │ │ ├── SessionService.kt │ │ │ ├── MeColorState.kt │ │ │ ├── SafePacemakerDatabase.kt │ │ │ ├── HeartRateSensorsState.kt │ │ │ ├── launchHeartRateSensorAutoConnector.kt │ │ │ ├── BluetoothState.kt │ │ │ ├── ApplicationBackend.kt │ │ │ ├── launchAdhocUserActor.kt │ │ │ ├── ApplicationFeature.kt │ │ │ ├── launchHeartRateUtteranceProducer.kt │ │ │ ├── MeState.kt │ │ │ ├── sessionActor.kt │ │ │ ├── SqlSessionService.kt │ │ │ └── launchPacemakerBroadcastSender.kt │ ├── androidMain │ │ ├── play_store_512.png │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_background.png │ │ │ │ ├── ic_launcher_foreground.png │ │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_background.png │ │ │ │ ├── ic_launcher_foreground.png │ │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_background.png │ │ │ │ ├── ic_launcher_foreground.png │ │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_background.png │ │ │ │ ├── ic_launcher_foreground.png │ │ │ │ └── ic_launcher_monochrome.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_background.png │ │ │ │ ├── ic_launcher_foreground.png │ │ │ │ └── ic_launcher_monochrome.png │ │ │ └── mipmap-anydpi-v26 │ │ │ │ └── ic_launcher.xml │ │ ├── kotlin │ │ │ └── io │ │ │ │ └── sellmair │ │ │ │ └── pacemaker │ │ │ │ ├── ApplicationBackend.android.kt │ │ │ │ ├── BluetoothState.android.kt │ │ │ │ ├── AndroidContextProvider.kt │ │ │ │ ├── launchVibrationActor.android.kt │ │ │ │ └── launchTextToSpeech.kt │ │ └── AndroidManifest.xml │ ├── jvmMain │ │ └── kotlin │ │ │ └── io │ │ │ └── sellmair │ │ │ └── pacemaker │ │ │ ├── BluetoothState.jvm.kt │ │ │ └── ApplicationBackend.jvm.kt │ ├── iosMain │ │ └── kotlin │ │ │ └── io │ │ │ └── sellmair │ │ │ └── pacemaker │ │ │ ├── ApplicationBackend.apple.kt │ │ │ ├── BluetoothState.ios.kt │ │ │ ├── launchVibrationActor.apple.kt │ │ │ ├── launchSpeechSynthesizer.kt │ │ │ └── IosApplicationBackend.kt │ ├── androidUnitTest │ │ └── kotlin │ │ │ ├── utils │ │ │ └── InMemoryDatabase.kt │ │ │ └── GroupStateTest.kt │ └── sql │ │ └── io │ │ └── sellmair │ │ └── pacemaker │ │ ├── Session.sq │ │ └── User.sq └── build.gradle.kts ├── .img ├── screenshot-ios-1.png ├── screenshot-ios-2.png └── screenshot-android-1.png ├── iosApp ├── Podfile ├── Pacemaker │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── app-logo-ios.png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── PacemakerApp.swift │ ├── Info.plist │ └── ContentView.swift ├── Pacemaker.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ ├── xcuserdata │ │ │ └── sebastiansellmair.xcuserdatad │ │ │ │ └── UserInterfaceState.xcuserstate │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ │ └── sebastiansellmair.xcuserdatad │ │ └── xcschemes │ │ └── xcschememanagement.plist └── Pacemaker.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── .fleet └── settings.json ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── models ├── build.gradle.kts └── src │ ├── commonMain │ └── kotlin │ │ └── io │ │ └── sellmair │ │ └── pacemaker │ │ └── model │ │ ├── SessionId.kt │ │ ├── HeartRateSensorId.kt │ │ ├── HeartRateSensorInfo.kt │ │ ├── Session.kt │ │ ├── UserId.kt │ │ ├── Hue.kt │ │ ├── User.kt │ │ └── HeartRate.kt │ └── commonTest │ └── kotlin │ └── HeartRateEncodingTest.kt ├── bluetooth-core ├── src │ ├── commonMain │ │ └── kotlin │ │ │ └── io │ │ │ └── sellmair │ │ │ └── pacemaker │ │ │ └── ble │ │ │ ├── BleUUID.kt │ │ │ ├── logging.kt │ │ │ ├── BleDeviceId.kt │ │ │ ├── BleCharacteristicDescriptor.kt │ │ │ ├── BleServiceDescriptor.kt │ │ │ ├── BleCentralControkller.kt │ │ │ ├── BleOperation.kt │ │ │ ├── BleConnectableController.kt │ │ │ ├── BlePeripheralController.kt │ │ │ ├── BleStatusCode.kt │ │ │ ├── impl │ │ │ ├── BleConnectionImpl.kt │ │ │ └── BleCentralServiceImpl.kt │ │ │ └── Ble.kt │ ├── jvmMain │ │ └── kotlin │ │ │ └── io │ │ │ └── sellmair │ │ │ └── pacemaker │ │ │ └── ble │ │ │ └── BleUUID.jvm.kt │ ├── androidMain │ │ └── kotlin │ │ │ └── io │ │ │ └── sellmair │ │ │ └── pacemaker │ │ │ └── ble │ │ │ ├── AndroidBleUUID.kt │ │ │ ├── AndroidConnectableHardware.kt │ │ │ ├── AndroidCentralHardware.kt │ │ │ ├── AndroidBluetoothExtensions.kt │ │ │ └── AndroidBleImpl.kt │ └── appleMain │ │ └── kotlin │ │ └── io │ │ └── sellmair │ │ └── pacemaker │ │ └── ble │ │ ├── AppleBleUUID.kt │ │ ├── AppleConnectableHardware.kt │ │ ├── AppleBluetoothExtensions.kt │ │ ├── AppleCentralHardware.kt │ │ ├── AppleBle.kt │ │ ├── AppleCBMutableServiceFactory.kt │ │ ├── ApplePeripheralHardware.kt │ │ ├── AppleCentralController.kt │ │ └── ApplePeripheralManagerDelegate.kt └── build.gradle.kts ├── bluetooth ├── src │ └── commonMain │ │ └── kotlin │ │ └── io │ │ └── sellmair │ │ └── pacemaker │ │ └── bluetooth │ │ ├── PacemakerBroadcastPackageEvent.kt │ │ ├── Extensions.kt │ │ ├── HeartRateSensorMeasurement.kt │ │ ├── PacemakerBluetoothWritable.kt │ │ ├── PacemakerBroadcastPackage.kt │ │ ├── HeartRateMeasurementEvent.kt │ │ ├── PacemakerServiceConstants.kt │ │ ├── HeartRateSensorServiceDescriptors.kt │ │ ├── PacemakerBluetoothConnection.kt │ │ ├── PacemakerBluetoothWritableImpl.kt │ │ ├── PacemakerBluetoothService.kt │ │ └── PacemakerServiceDescriptors.kt └── build.gradle.kts ├── utils ├── src │ ├── commonMain │ │ └── kotlin │ │ │ └── io │ │ │ └── sellmair │ │ │ └── pacemaker │ │ │ └── utils │ │ │ ├── coroutineUtils.kt │ │ │ ├── ioUtils.kt │ │ │ ├── conversionUtils.kt │ │ │ ├── Configuration.kt │ │ │ └── Logging.kt │ ├── jvmAndAndroidMain │ │ └── kotlin │ │ │ └── ioUtils.jvmAndAndroid.kt │ ├── nonAndroidMain │ │ └── kotlin │ │ │ └── Logging.nonAndroid.kt │ ├── androidMain │ │ └── kotlin │ │ │ └── Logging.android.kt │ ├── nativeMain │ │ └── kotlin │ │ │ └── ioUtils.native.kt │ └── commonTest │ │ └── kotlin │ │ └── io │ │ └── sellmair │ │ └── pacemaker │ │ └── utils │ │ └── ConfigurationTest.kt └── build.gradle.kts ├── gradle.properties ├── .gitignore ├── DataProtection.md ├── spoof-tool ├── build.gradle.kts └── src │ └── macosMain │ └── kotlin │ └── io.sellmair.broadheart.spoof │ └── Main.kt ├── .github └── workflows │ └── build.yaml ├── settings.gradle.kts ├── .run └── 🔥app [jvm].run.xml ├── dependencies.toml └── gradlew.bat /app/logo/app-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app/logo/app-logo.png -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/Settings.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/Intent.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui 2 | 3 | -------------------------------------------------------------------------------- /.img/screenshot-ios-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/.img/screenshot-ios-1.png -------------------------------------------------------------------------------- /.img/screenshot-ios-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/.img/screenshot-ios-2.png -------------------------------------------------------------------------------- /app/logo/app-logo-ios.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app/logo/app-logo-ios.png -------------------------------------------------------------------------------- /iosApp/Podfile: -------------------------------------------------------------------------------- 1 | target 'Pacemaker' do 2 | platform :ios, '14.1' 3 | pod 'PM', :path => '../app' 4 | end -------------------------------------------------------------------------------- /.img/screenshot-android-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/.img/screenshot-android-1.png -------------------------------------------------------------------------------- /.fleet/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "backend.maxHeapSizeMb": 4096, 3 | "run.destination.stop.already.running": "Always" 4 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/androidMain/play_store_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app/src/androidMain/play_store_512.png -------------------------------------------------------------------------------- /iosApp/Pacemaker/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /app-core/src/androidMain/play_store_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/play_store_512.png -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/SqlDdriver.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import app.cash.sqldelight.db.SqlDriver 4 | 5 | -------------------------------------------------------------------------------- /iosApp/Pacemaker/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /models/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("pacemaker-library") 3 | } 4 | 5 | pacemaker { 6 | jvm() 7 | ios() 8 | macos() 9 | } 10 | -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/Page.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui 2 | 3 | enum class Page { 4 | MainPage, TimelinePage, SettingsPage 5 | } -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleUUID.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | expect fun BleUUID(value: String): BleUUID 4 | expect class BleUUID -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /iosApp/Pacemaker/Assets.xcassets/AppIcon.appiconset/app-logo-ios.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/iosApp/Pacemaker/Assets.xcassets/AppIcon.appiconset/app-logo-ios.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/app-core/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/logging.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.utils.LogTag 4 | 5 | val LogTag.Companion.appCore get() = LogTag("app-core") -------------------------------------------------------------------------------- /models/src/commonMain/kotlin/io/sellmair/pacemaker/model/SessionId.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.model 2 | 3 | import kotlin.jvm.JvmInline 4 | 5 | @JvmInline 6 | value class SessionId(val value: Long) -------------------------------------------------------------------------------- /bluetooth-core/src/jvmMain/kotlin/io/sellmair/pacemaker/ble/BleUUID.jvm.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | actual fun BleUUID(value: String): BleUUID = value 4 | 5 | actual typealias BleUUID = String 6 | -------------------------------------------------------------------------------- /models/src/commonMain/kotlin/io/sellmair/pacemaker/model/HeartRateSensorId.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.model 2 | 3 | import kotlin.jvm.JvmInline 4 | 5 | @JvmInline 6 | value class HeartRateSensorId(val value: String) -------------------------------------------------------------------------------- /models/src/commonMain/kotlin/io/sellmair/pacemaker/model/HeartRateSensorInfo.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.model 2 | 3 | data class HeartRateSensorInfo( 4 | val id: HeartRateSensorId, 5 | val rssi: Int? = null, 6 | ) -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/logging.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui 2 | 3 | import io.sellmair.pacemaker.utils.LogTag 4 | 5 | private val uiTag = LogTag("ui") 6 | 7 | val LogTag.Companion.ui get() = uiTag -------------------------------------------------------------------------------- /app/src/androidMain/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /iosApp/Pacemaker.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /iosApp/Pacemaker.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/jvmMain/kotlin/io/sellmair/pacemaker/ui/BackHandlerIfAny.jvm.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui 2 | 3 | @androidx.compose.runtime.Composable 4 | internal actual fun BackHandlerIfAny(enabled: Boolean, onBack: () -> Unit) { 5 | } 6 | -------------------------------------------------------------------------------- /app/src/appleMain/kotlin/BackHandlerIfAny.apple.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | @Composable 6 | internal actual fun BackHandlerIfAny(enabled: Boolean, onBack: () -> Unit) = Unit 7 | -------------------------------------------------------------------------------- /iosApp/Pacemaker/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/logging.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import io.sellmair.pacemaker.utils.LogTag 4 | 5 | private val bleTag = LogTag("ble") 6 | 7 | val LogTag.Companion.ble get() = bleTag 8 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/BackHandlerIfAny.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | @Composable 6 | internal expect fun BackHandlerIfAny(enabled: Boolean = true, onBack: () -> Unit) 7 | -------------------------------------------------------------------------------- /bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleDeviceId.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import kotlin.jvm.JvmInline 4 | 5 | @JvmInline 6 | value class BleDeviceId(val value: String) { 7 | override fun toString(): String = value 8 | } -------------------------------------------------------------------------------- /bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerBroadcastPackageEvent.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.bluetooth 2 | 3 | import io.sellmair.evas.Event 4 | 5 | data class PacemakerBroadcastPackageEvent(val pkg: PacemakerBroadcastPackage): Event -------------------------------------------------------------------------------- /models/src/commonMain/kotlin/io/sellmair/pacemaker/model/Session.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.model 2 | 3 | import kotlin.time.Instant 4 | 5 | data class Session( 6 | val id: SessionId, 7 | val startTime: Instant, 8 | val endTime: Instant? 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/mainPage/MainPage.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.mainPage 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | @Composable 6 | internal fun MainPage() { 7 | GroupHeartRateOverview() 8 | MyStatusHeader() 9 | } -------------------------------------------------------------------------------- /app/src/androidMain/res/xml/locales_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /iosApp/Pacemaker.xcodeproj/project.xcworkspace/xcuserdata/sebastiansellmair.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sellmair/pacemaker/HEAD/iosApp/Pacemaker.xcodeproj/project.xcworkspace/xcuserdata/sebastiansellmair.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed May 29 16:30:04 CEST 2024 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/Extensions.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.bluetooth 2 | 3 | import io.sellmair.pacemaker.ble.BleDeviceId 4 | import io.sellmair.pacemaker.model.HeartRateSensorId 5 | 6 | fun BleDeviceId.toHeartRateSensorId() = HeartRateSensorId(value) -------------------------------------------------------------------------------- /app/src/androidMain/kotlin/AndroidBackHandler.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | @Composable 6 | actual fun BackHandlerIfAny(enabled: Boolean, onBack: () -> Unit) { 7 | androidx.activity.compose.BackHandler(enabled = enabled, onBack = onBack) 8 | } -------------------------------------------------------------------------------- /bluetooth-core/src/androidMain/kotlin/io/sellmair/pacemaker/ble/AndroidBleUUID.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import java.util.* 4 | 5 | actual fun BleUUID(value: String): BleUUID = UUID.fromString(value) 6 | 7 | actual typealias BleUUID = UUID 8 | 9 | fun BleUUID(uuid: UUID) : BleUUID = uuid -------------------------------------------------------------------------------- /app-core/src/jvmMain/kotlin/io/sellmair/pacemaker/BluetoothState.jvm.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | internal actual suspend fun isBluetoothEnabled(): Boolean { 4 | return true 5 | } 6 | 7 | internal actual suspend fun isBluetoothPermissionGranted(): Boolean { 8 | return true 9 | } 10 | -------------------------------------------------------------------------------- /utils/src/commonMain/kotlin/io/sellmair/pacemaker/utils/coroutineUtils.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.utils 2 | 3 | import kotlin.coroutines.suspendCoroutine 4 | 5 | /** 6 | * This function will return 'never' 7 | */ 8 | suspend fun never(): Nothing { 9 | suspendCoroutine {} 10 | } 11 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/UtteranceEvent.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.evas.Event 4 | 5 | data class UtteranceEvent( 6 | val type: Type, 7 | val message: String 8 | ): Event { 9 | enum class Type { 10 | Info, Warning 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/launchFrontendServices.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.ui.launchHeartRateUtteranceActor 4 | import kotlinx.coroutines.CoroutineScope 5 | 6 | fun CoroutineScope.launchFrontendServices() { 7 | launchHeartRateUtteranceActor() 8 | } 9 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/colorUtils.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import io.sellmair.pacemaker.HSLColor 5 | 6 | fun HSLColor.toColor(alpha: Float = 1f): Color { 7 | return Color.hsl(hue, saturation, lightness, alpha = alpha) 8 | } 9 | -------------------------------------------------------------------------------- /bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/AppleBleUUID.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import platform.CoreBluetooth.CBUUID 4 | import platform.CoreBluetooth.CBUUID.Companion.UUIDWithString 5 | 6 | actual fun BleUUID(value: String): BleUUID = UUIDWithString(value) 7 | actual typealias BleUUID = CBUUID -------------------------------------------------------------------------------- /iosApp/Pacemaker.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /iosApp/Pacemaker/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "app-logo-ios.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /iosApp/Pacemaker.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/AppleConnectableHardware.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import platform.CoreBluetooth.CBPeripheral 4 | 5 | internal class AppleConnectableHardware( 6 | val peripheral: CBPeripheral, 7 | val delegate: ApplePeripheralDelegate, 8 | val serviceDescriptor: BleServiceDescriptor 9 | ) -------------------------------------------------------------------------------- /utils/src/jvmAndAndroidMain/kotlin/ioUtils.jvmAndAndroid.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.utils 2 | 3 | import okio.FileSystem 4 | 5 | /* https://youtrack.jetbrains.com/issue/KTIJ-25140/Okio-False-positive-MISSINGDEPENDENCYSUPERCLASS-on-FileSystem.SYSTEM */ 6 | @Suppress("UNUSED") 7 | actual fun defaultFileSystem(): FileSystem { 8 | return FileSystem.SYSTEM 9 | } -------------------------------------------------------------------------------- /iosApp/Pacemaker/PacemakerApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PacemakerApp.swift 3 | // Pacemaker 4 | // 5 | // Created by Sebastian Sellmair on 13.03.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct PacemakerApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView().edgesIgnoringSafeArea([.bottom, .top]) } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/OnHeartRateScaleSpring.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.widget 2 | 3 | import androidx.compose.animation.core.Spring 4 | import androidx.compose.animation.core.spring 5 | 6 | fun onHeartRateScaleSpring() = spring( 7 | dampingRatio = Spring.DampingRatioMediumBouncy, 8 | stiffness = Spring.StiffnessLow 9 | ) -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | android.useAndroidX=true 2 | kotlin.code.style=official 3 | org.jetbrains.compose.experimental.uikit.enabled=true 4 | org.jetbrains.compose.experimental.macos.enabled=true 5 | org.gradle.jvmargs=-Xmx12g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g 6 | org.gradle.configuration-cache=true 7 | org.gradle.caching=true 8 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/UserState.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.model.HeartRate 4 | import io.sellmair.pacemaker.model.User 5 | 6 | data class UserState( 7 | val user: User, 8 | val isMe: Boolean, 9 | val heartRate: HeartRate, 10 | val heartRateLimit: HeartRate?, 11 | val color: HSLColor 12 | ) -------------------------------------------------------------------------------- /bluetooth-core/src/androidMain/kotlin/io/sellmair/pacemaker/ble/AndroidConnectableHardware.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import android.bluetooth.BluetoothDevice 4 | import android.content.Context 5 | 6 | internal class AndroidConnectableHardware( 7 | val context: Context, 8 | val device: BluetoothDevice, 9 | val callback: AndroidGattCallback 10 | ) 11 | 12 | -------------------------------------------------------------------------------- /app/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/AppleBluetoothExtensions.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import platform.CoreBluetooth.CBCentral 4 | import platform.CoreBluetooth.CBPeripheral 5 | 6 | val CBPeripheral.deviceId: BleDeviceId get() = BleDeviceId(this.identifier.UUIDString) 7 | val CBCentral.deviceId: BleDeviceId get() = BleDeviceId(this.identifier.UUIDString) 8 | -------------------------------------------------------------------------------- /app-core/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app-core/src/androidMain/kotlin/io/sellmair/pacemaker/ApplicationBackend.android.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.launch 5 | 6 | actual fun ApplicationBackend.launchPlatform(scope: CoroutineScope) { 7 | this as AndroidApplicationBackend 8 | scope.launchVibrationWarningActor(this) 9 | scope.launchTextToSpeech(this) 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/build/** 2 | .gradle 3 | .kotlin 4 | local.properties 5 | .idea 6 | spoof-tool/spoof 7 | 8 | # Ios specific: 9 | .DS_Store 10 | iosApp/Podfile.lock 11 | iosApp/Pods/* 12 | iosApp/Pacemaker.xcworkspace/* 13 | iosApp/Pacemaker.xcodeproj/* 14 | !iosApp/Pacemaker.xcodeproj/project.pbxproj 15 | app/PM.podspec 16 | 17 | # Addition 18 | /captures 19 | .externalNativeBuild 20 | .cxx 21 | /app/release/ 22 | -------------------------------------------------------------------------------- /app-core/src/iosMain/kotlin/io/sellmair/pacemaker/ApplicationBackend.apple.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import kotlinx.cinterop.ExperimentalForeignApi 4 | import kotlinx.coroutines.CoroutineScope 5 | 6 | 7 | @OptIn(ExperimentalForeignApi::class) 8 | actual fun ApplicationBackend.launchPlatform(scope: CoroutineScope) { 9 | scope.launchVibrationWarningActor() 10 | scope.launchSpeechSynthesizer() 11 | } -------------------------------------------------------------------------------- /bluetooth-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("pacemaker-library") 3 | } 4 | 5 | pacemaker { 6 | android() 7 | ios() 8 | macos() 9 | jvm() 10 | } 11 | 12 | kotlin { 13 | sourceSets.commonMain.dependencies { 14 | api(project(":models")) 15 | } 16 | 17 | sourceSets.androidMain.dependencies { 18 | implementation("androidx.annotation:annotation:1.7.0") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /utils/src/nonAndroidMain/kotlin/Logging.nonAndroid.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.utils 2 | 3 | actual object Log { 4 | actual operator fun invoke(tag: LogTag, level: LogLevel, message: String, throwable: Throwable?) { 5 | println( 6 | "[$tag | $level]: $message" + if (throwable != null) { 7 | "\n${throwable.stackTraceToString()}" 8 | } else "" 9 | ) 10 | } 11 | } -------------------------------------------------------------------------------- /bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/HeartRateSensorMeasurement.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.bluetooth 2 | 3 | import io.sellmair.pacemaker.model.HeartRate 4 | import io.sellmair.pacemaker.model.HeartRateSensorInfo 5 | import kotlin.time.Instant 6 | 7 | data class HeartRateSensorMeasurement( 8 | val heartRate: HeartRate, 9 | val sensorInfo: HeartRateSensorInfo, 10 | val receivedTime: Instant 11 | ) 12 | -------------------------------------------------------------------------------- /utils/src/commonMain/kotlin/io/sellmair/pacemaker/utils/ioUtils.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.utils 2 | 3 | import okio.FileSystem 4 | import okio.Path 5 | import okio.buffer 6 | import okio.use 7 | 8 | fun FileSystem.readUtf8OrNull(path: Path): String? { 9 | return try { 10 | source(path).buffer().use { it.readUtf8() } 11 | } catch (t: Throwable) { 12 | null 13 | } 14 | } 15 | 16 | expect fun defaultFileSystem(): FileSystem -------------------------------------------------------------------------------- /bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleCharacteristicDescriptor.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | data class BleCharacteristicDescriptor( 4 | val name: String, 5 | val uuid: BleUUID, 6 | val isReadable: Boolean = true, 7 | val isWritable: Boolean = false, 8 | val isNotificationsEnabled: Boolean = false 9 | ) { 10 | override fun toString(): String { 11 | return "Characteristic($name)" 12 | } 13 | } -------------------------------------------------------------------------------- /bluetooth/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("pacemaker-library") 3 | } 4 | 5 | pacemaker { 6 | android() 7 | ios() 8 | macos() 9 | jvm() 10 | } 11 | 12 | kotlin { 13 | sourceSets.commonMain.dependencies { 14 | api(project(":bluetooth-core")) 15 | implementation(Dependencies.okio) 16 | } 17 | 18 | sourceSets.androidMain.dependencies { 19 | implementation("androidx.annotation:annotation:1.7.0") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/SessionRecord.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.model.HeartRate 4 | import io.sellmair.pacemaker.model.SessionId 5 | import io.sellmair.pacemaker.model.UserId 6 | import kotlin.time.Instant 7 | 8 | data class SessionRecord( 9 | val sessionId: SessionId, 10 | val userId: UserId, 11 | val time: Instant, 12 | val heartRate: HeartRate, 13 | val heartRateLimit: HeartRate? 14 | ) 15 | -------------------------------------------------------------------------------- /app/src/iosMain/kotlin/backend.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.SupervisorJob 6 | 7 | internal val backend by lazy { 8 | IosApplicationBackend().also { backend -> 9 | val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + backend.events + backend.states) 10 | scope.launchFrontendServices() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app-core/src/androidUnitTest/kotlin/utils/InMemoryDatabase.kt: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver 4 | import io.sellmair.pacemaker.SafePacemakerDatabase 5 | import io.sellmair.pacemaker.sql.PacemakerDatabase 6 | 7 | internal fun createInMemoryDatabase(): SafePacemakerDatabase = SafePacemakerDatabase { 8 | val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) 9 | PacemakerDatabase.Schema.create(driver) 10 | PacemakerDatabase(driver) 11 | } 12 | -------------------------------------------------------------------------------- /app-core/src/androidMain/kotlin/io/sellmair/pacemaker/BluetoothState.android.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.ble.isBluetoothEnabled 4 | import io.sellmair.pacemaker.ble.isBluetoothPermissionGranted 5 | 6 | internal actual suspend fun isBluetoothEnabled(): Boolean { 7 | return androidContext().isBluetoothEnabled() 8 | } 9 | 10 | internal actual suspend fun isBluetoothPermissionGranted(): Boolean { 11 | return androidContext().isBluetoothPermissionGranted() 12 | } 13 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/meId.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import com.russhwolf.settings.Settings 4 | import com.russhwolf.settings.set 5 | import io.sellmair.pacemaker.model.UserId 6 | import kotlin.random.Random 7 | 8 | internal val Settings.meId: UserId 9 | get() { 10 | val key = "me.id" 11 | getLongOrNull(key)?.let { return UserId(it) } 12 | val newId = Random.nextLong() 13 | set(key, newId) 14 | return UserId(newId) 15 | } 16 | -------------------------------------------------------------------------------- /bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerBluetoothWritable.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.bluetooth 2 | 3 | import io.sellmair.pacemaker.model.HeartRate 4 | import io.sellmair.pacemaker.model.Hue 5 | import io.sellmair.pacemaker.model.User 6 | 7 | interface PacemakerBluetoothWritable { 8 | suspend fun setUser(user: User) 9 | suspend fun setHeartRate(heartRate: HeartRate) 10 | suspend fun setHeartRateLimit(heartRate: HeartRate) 11 | suspend fun setColorHue(hue: Hue) 12 | } 13 | -------------------------------------------------------------------------------- /models/src/commonTest/kotlin/HeartRateEncodingTest.kt: -------------------------------------------------------------------------------- 1 | import io.sellmair.pacemaker.model.HeartRate 2 | import io.sellmair.pacemaker.model.encodeToByteArray 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | 6 | class HeartRateEncodingTest { 7 | @Test 8 | fun `test - 130`() { 9 | assertEquals(HeartRate(130), HeartRate(HeartRate(130).encodeToByteArray())) 10 | } 11 | 12 | @Test 13 | fun `test - 10`() { 14 | assertEquals(HeartRate(10f), HeartRate(HeartRate(10f).encodeToByteArray())) 15 | } 16 | } -------------------------------------------------------------------------------- /bluetooth-core/src/androidMain/kotlin/io/sellmair/pacemaker/ble/AndroidCentralHardware.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import android.bluetooth.BluetoothManager 4 | import android.content.Context 5 | 6 | internal fun AndroidCentralHardware(context: Context): AndroidCentralHardware { 7 | val manager = context.getSystemService(BluetoothManager::class.java) 8 | return AndroidCentralHardware(context, manager) 9 | } 10 | 11 | internal class AndroidCentralHardware( 12 | val context: Context, 13 | val manager: BluetoothManager 14 | ) 15 | -------------------------------------------------------------------------------- /bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleServiceDescriptor.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | data class BleServiceDescriptor( 4 | val name: String, 5 | val uuid: BleUUID, 6 | val characteristics: Set 7 | ) { 8 | private val characteristicsByUUID = characteristics.associateBy { it.uuid } 9 | 10 | fun findCharacteristic(uuid: BleUUID): BleCharacteristicDescriptor? = characteristicsByUUID[uuid] 11 | 12 | override fun toString(): String { 13 | return "Service($name)" 14 | } 15 | } -------------------------------------------------------------------------------- /utils/src/androidMain/kotlin/Logging.android.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.utils 2 | 3 | actual object Log { 4 | actual operator fun invoke(tag: LogTag, level: LogLevel, message: String, throwable: Throwable?) { 5 | when (level) { 6 | LogLevel.Debug -> android.util.Log.d(tag.name, message, throwable) 7 | LogLevel.Info -> android.util.Log.i(tag.name, message, throwable) 8 | LogLevel.Warn -> android.util.Log.w(tag.name, message, throwable) 9 | LogLevel.Error -> android.util.Log.e(tag.name, message, throwable) 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /utils/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree 2 | 3 | plugins { 4 | id("pacemaker-library") 5 | } 6 | 7 | pacemaker { 8 | jvm() 9 | macos() 10 | android() 11 | ios() 12 | 13 | sourceSets { 14 | useNonAndroid() 15 | useJvmAndAndroid() 16 | } 17 | 18 | features { 19 | useAtomicFu() 20 | } 21 | } 22 | 23 | kotlin { 24 | androidTarget { 25 | @Suppress("OPT_IN_USAGE") 26 | instrumentedTestVariant { 27 | sourceSetTree = KotlinSourceSetTree.test 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /iosApp/Pacemaker/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LSMinimumSystemVersion 6 | 14.0 7 | CADisableMinimumFrameDurationOnPhone 8 | 9 | ITSAppUsesNonExemptEncryption 10 | 11 | UIBackgroundModes 12 | 13 | audio 14 | bluetooth-central 15 | bluetooth-peripheral 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /DataProtection.md: -------------------------------------------------------------------------------- 1 | ### Data Projection 2 | The Pacemaker App does not require an Internet connection and does not share, nor store, any information central. 3 | All information stored by the APP is stored on the users devices. 4 | 5 | ### Broadcasting Information to nearby users. 6 | By the nature of this App, some data is visible to nearby users (shared currently with Bluetooth). 7 | This information includes: 8 | - The current Heart Rate of the user 9 | - The defined 'Heart Rate limit' of the user 10 | - The defined user name 11 | - The userId (which is a random number assigned at first start of the application) 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/settingsPage/SettingsPage.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.settingsPage 2 | 3 | import androidx.compose.runtime.Composable 4 | import io.sellmair.evas.compose.composeValue 5 | import io.sellmair.pacemaker.HeartRateSensorsState 6 | import io.sellmair.pacemaker.MeState 7 | 8 | 9 | @Composable 10 | internal fun SettingsPage() { 11 | val heartRateSensors = HeartRateSensorsState.composeValue().nearbySensors 12 | val me = MeState.composeValue()?.me ?: return 13 | 14 | SettingsPageContent( 15 | me = me, 16 | heartRateSensors = heartRateSensors 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleCentralControkller.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import kotlinx.coroutines.channels.ReceiveChannel 4 | 5 | internal interface BleCentralController { 6 | 7 | fun startScanning() 8 | 9 | val scanResults: ReceiveChannel 10 | 11 | val connectedDevices: ReceiveChannel 12 | 13 | fun createConnectableController(result: ScanResult): BleConnectableController 14 | 15 | interface ScanResult { 16 | val deviceId: BleDeviceId 17 | val rssi: Int 18 | val isConnectable: Boolean 19 | } 20 | } -------------------------------------------------------------------------------- /bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerBroadcastPackage.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.bluetooth 2 | 3 | import io.sellmair.pacemaker.ble.BleDeviceId 4 | import io.sellmair.pacemaker.model.HeartRate 5 | import io.sellmair.pacemaker.model.Hue 6 | import io.sellmair.pacemaker.model.UserId 7 | import kotlin.time.Instant 8 | 9 | data class PacemakerBroadcastPackage( 10 | val receivedTime: Instant, 11 | val deviceId: BleDeviceId, 12 | val userId: UserId, 13 | val userName: String, 14 | val heartRate: HeartRate, 15 | val heartRateLimit: HeartRate, 16 | val userColorHue: Hue?, 17 | ) 18 | -------------------------------------------------------------------------------- /models/src/commonMain/kotlin/io/sellmair/pacemaker/model/UserId.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.model 2 | 3 | import okio.Buffer 4 | import kotlin.jvm.JvmInline 5 | import kotlin.random.Random 6 | 7 | @JvmInline 8 | value class UserId(val value: Long) 9 | 10 | fun UserId.encodeToByteArray(): ByteArray { 11 | return Buffer().writeLong(value).readByteArray() 12 | } 13 | 14 | fun UserId(data: ByteArray): UserId? { 15 | val id = runCatching { 16 | Buffer().write(data).readLong() 17 | }.getOrNull() ?: return null 18 | return UserId(id) 19 | } 20 | 21 | fun randomUserId(): UserId { 22 | return UserId(Random.nextLong()) 23 | } -------------------------------------------------------------------------------- /app-core/src/iosMain/kotlin/io/sellmair/pacemaker/BluetoothState.ios.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import platform.CoreBluetooth.CBCentralManager 4 | import platform.CoreBluetooth.CBCentralManagerStatePoweredOff 5 | import platform.CoreBluetooth.CBManager 6 | import platform.CoreBluetooth.CBManagerAuthorizationAllowedAlways 7 | 8 | 9 | internal actual suspend fun isBluetoothEnabled(): Boolean { 10 | return CBCentralManager().state != CBCentralManagerStatePoweredOff 11 | } 12 | 13 | internal actual suspend fun isBluetoothPermissionGranted(): Boolean { 14 | return CBManager.authorization() == CBManagerAuthorizationAllowedAlways 15 | } 16 | -------------------------------------------------------------------------------- /utils/src/nativeMain/kotlin/ioUtils.native.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.utils 2 | 3 | import kotlinx.cinterop.BetaInteropApi 4 | import kotlinx.cinterop.ExperimentalForeignApi 5 | import kotlinx.cinterop.memScoped 6 | import kotlinx.cinterop.toCValues 7 | import okio.FileSystem 8 | import platform.Foundation.NSData 9 | import platform.Foundation.create 10 | 11 | actual fun defaultFileSystem(): FileSystem { 12 | return FileSystem.SYSTEM 13 | } 14 | 15 | @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) 16 | fun ByteArray.toNSData(): NSData { 17 | return memScoped { 18 | NSData.create(bytes = this@toNSData.toCValues().ptr, length = size.toULong()) 19 | } 20 | } -------------------------------------------------------------------------------- /app-core/src/androidMain/kotlin/io/sellmair/pacemaker/AndroidContextProvider.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import android.content.Context 4 | import kotlinx.coroutines.currentCoroutineContext 5 | import kotlin.coroutines.CoroutineContext 6 | 7 | internal class AndroidContextProvider(val context: Context) : CoroutineContext.Element { 8 | override val key: CoroutineContext.Key<*> = Key 9 | 10 | companion object Key : CoroutineContext.Key 11 | } 12 | 13 | suspend fun androidContext(): Context { 14 | val provider = currentCoroutineContext()[AndroidContextProvider] ?: error("Missing Android 'Context'") 15 | return provider.context 16 | } 17 | 18 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/ApplicationWindow.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui 2 | 3 | import androidx.compose.runtime.Composable 4 | import io.sellmair.pacemaker.ui.mainPage.MainPage 5 | import io.sellmair.pacemaker.ui.settingsPage.SettingsPage 6 | import io.sellmair.pacemaker.ui.timelinePage.TimelinePage 7 | 8 | 9 | @Composable 10 | internal fun ApplicationWindow() { 11 | PacemakerTheme { 12 | PageRouter { page -> 13 | when (page) { 14 | Page.MainPage -> MainPage() 15 | Page.TimelinePage -> TimelinePage() 16 | Page.SettingsPage -> SettingsPage() 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleOperation.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import io.sellmair.pacemaker.utils.invoke 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.withContext 6 | 7 | typealias BleSimpleOperation = BleOperation 8 | 9 | interface BleOperation { 10 | val description: String 11 | suspend fun CoroutineScope.invoke(): BleResult 12 | } 13 | 14 | internal suspend infix fun BleQueue.enqueue(operation: BleOperation): BleResult { 15 | return withContext(BleQueue.OperationTitle.invoke(operation.description)) { 16 | enqueue { with(operation) { invoke() } } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/HeartRateMeasurementEvent.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.bluetooth 2 | 3 | import io.sellmair.evas.Event 4 | import io.sellmair.pacemaker.model.HeartRate 5 | import io.sellmair.pacemaker.model.HeartRateSensorId 6 | import kotlin.time.Instant 7 | 8 | data class HeartRateMeasurementEvent( 9 | val heartRate: HeartRate, 10 | val sensorId: HeartRateSensorId, 11 | val time: Instant 12 | ): Event 13 | 14 | fun HeartRateSensorMeasurement.toEvent() : HeartRateMeasurementEvent { 15 | return HeartRateMeasurementEvent( 16 | heartRate = heartRate, 17 | sensorId = sensorInfo.id, 18 | time = receivedTime 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerServiceConstants.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.bluetooth 2 | 3 | internal object PacemakerServiceConstants { 4 | const val serviceUuidString = "35b6d6ed-b85f-48e6-8f21-26c9877dbbe8" 5 | const val userIdCharacteristicUuidString = "31F43DE6-19F0-4A2B-925B-77C05E4A05F1" 6 | const val userNameCharacteristicUuidString = "AAA5FD4B-33EB-43B4-84CD-54E53992DAA2" 7 | const val userColorHueCharacteristcUuidString = "cb0cf9a3-2e43-4b98-98e0-49a2e90d7be3" 8 | const val heartRateCharacteristicUuidString = "97F6C220-EEA2-40E1-BF2A-EAD3F086BF2A" 9 | const val heartRateLimitCharacteristicUuidString = "57E050E8-A6FA-4770-8DFD-C5F12DDA8AE7" 10 | } -------------------------------------------------------------------------------- /utils/src/commonMain/kotlin/io/sellmair/pacemaker/utils/conversionUtils.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.utils 2 | 3 | import okio.Buffer 4 | 5 | fun Int.encodeToByteArray(): ByteArray { 6 | val buffer = Buffer() 7 | buffer.writeInt(this) 8 | return buffer.readByteArray() 9 | } 10 | 11 | fun ByteArray.decodeToInt(): Int { 12 | val buffer = Buffer() 13 | buffer.write(this) 14 | return buffer.readInt() 15 | } 16 | 17 | fun Long.encodeToByteArray(): ByteArray { 18 | val buffer = Buffer() 19 | buffer.writeLong(this) 20 | return buffer.readByteArray() 21 | } 22 | 23 | fun ByteArray.decodeToLong(): Long { 24 | val buffer = Buffer() 25 | buffer.write(this) 26 | return buffer.readLong() 27 | } -------------------------------------------------------------------------------- /app-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("pacemaker-library") 3 | id("app.cash.sqldelight") 4 | } 5 | 6 | pacemaker { 7 | ios() 8 | android() 9 | jvm() 10 | 11 | features { 12 | useSqlDelight { 13 | databases { 14 | create("PacemakerDatabase") { 15 | srcDirs(file("src/sql")) 16 | packageName = "io.sellmair.pacemaker.sql" 17 | } 18 | } 19 | } 20 | } 21 | } 22 | 23 | kotlin { 24 | sourceSets.commonMain.dependencies { 25 | api(project(":models")) 26 | api(project(":utils")) 27 | api(project(":bluetooth")) 28 | implementation(Dependencies.multiplatform_settings) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /models/src/commonMain/kotlin/io/sellmair/pacemaker/model/Hue.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.model 2 | 3 | import okio.Buffer 4 | import kotlin.jvm.JvmInline 5 | import kotlin.math.absoluteValue 6 | 7 | @JvmInline 8 | value class Hue private constructor(val value: Float) { 9 | companion object { 10 | fun safe(value: Float): Hue { 11 | return Hue(value.absoluteValue % 360f) 12 | } 13 | } 14 | } 15 | 16 | 17 | fun Hue(data: ByteArray): Hue? { 18 | return runCatching { 19 | val bits = Buffer().write(data).readInt() 20 | return Hue.safe(Float.fromBits(bits)) 21 | }.getOrNull() 22 | } 23 | 24 | fun Hue.encodeToByteArray(): ByteArray { 25 | return Buffer().writeInt(value.toBits()).readByteArray() 26 | } -------------------------------------------------------------------------------- /iosApp/Pacemaker.xcodeproj/xcuserdata/sebastiansellmair.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Copy of Pacemaker.xcscheme 8 | 9 | orderHint 10 | 1 11 | 12 | Pacemaker.xcscheme 13 | 14 | orderHint 15 | 0 16 | 17 | [Fleet] Pacemaker.xcscheme 18 | 19 | isShown 20 | 21 | orderHint 22 | 2 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /spoof-tool/build.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget 3 | 4 | plugins { 5 | id("pacemaker-application") 6 | } 7 | 8 | pacemaker { 9 | macos() 10 | } 11 | 12 | kotlin { 13 | sourceSets.getByName("commonMain").dependencies { 14 | implementation(project(":bluetooth")) 15 | implementation(Dependencies.coroutines_core) 16 | implementation(Dependencies.okio) 17 | } 18 | 19 | targets.withType().all { 20 | binaries.executable { 21 | entryPoint("io.sellmair.pacemaker.spoof.main") 22 | runTaskProvider?.configure { 23 | this.workingDir(projectDir) 24 | this.standardInput = System.`in` 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/GradientBackdrop.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.widget 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.graphics.Brush 8 | import io.sellmair.pacemaker.ui.MeColor 9 | import io.sellmair.pacemaker.ui.MeColorLight 10 | 11 | @Composable 12 | internal fun GradientBackdrop(modifier: Modifier) { 13 | Box( 14 | modifier = modifier.background( 15 | brush = Brush.verticalGradient( 16 | colors = listOf(MeColor(), MeColorLight()), 17 | startY = 0f, 18 | endY = 500f 19 | ) 20 | ) 21 | ) 22 | } -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/Headline.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.widget 2 | 3 | import androidx.compose.material3.Text 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.graphics.Color 7 | import androidx.compose.ui.text.TextStyle 8 | import androidx.compose.ui.text.font.FontWeight 9 | import androidx.compose.ui.unit.sp 10 | 11 | @Composable 12 | fun Headline(text: String, modifier: Modifier = Modifier) { 13 | Text( 14 | text = text, 15 | style = TextStyle.Headline, 16 | modifier = modifier, 17 | color = Color.Black 18 | ) 19 | } 20 | 21 | 22 | val TextStyle.Companion.Headline get() = TextStyle( 23 | fontSize = 28.sp, 24 | fontWeight = FontWeight.Bold 25 | ) 26 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | 8 | jobs: 9 | snapshot: 10 | runs-on: macOS-latest 11 | steps: 12 | - name: Checkout sources 13 | uses: actions/checkout@v4 14 | 15 | - uses: actions/setup-java@v4 16 | with: 17 | distribution: corretto 18 | java-version: 17 19 | cache: 'gradle' 20 | 21 | - name: Cache Kotlin Native 22 | uses: actions/cache@v4 23 | with: 24 | path: | 25 | ~/.konan 26 | key: ${{ runner.os }}-konan 27 | restore-keys: ${{ runner.os }}-konan 28 | 29 | - name: Setup Gradle 30 | uses: gradle/actions/setup-gradle@v3 31 | 32 | - name: Build with Gradle 33 | run: ./gradlew check assemble 34 | -------------------------------------------------------------------------------- /iosApp/Pacemaker/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Pacemaker 4 | // 5 | // Created by Sebastian Sellmair on 13.03.23. 6 | // 7 | 8 | import SwiftUI 9 | import LibPacemaker 10 | 11 | struct ComposeView: UIViewControllerRepresentable { 12 | func makeUIViewController(context: Context) -> UIViewController { 13 | IosPacemakerViewController().create() 14 | } 15 | 16 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} 17 | } 18 | 19 | struct ContentView: View { 20 | var body: some View { 21 | ComposeView() 22 | .ignoresSafeArea(.keyboard) // Compose has own keyboard handler 23 | } 24 | } 25 | 26 | struct ContentView_Previews: PreviewProvider { 27 | static var previews: some View { 28 | ContentView() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app-core/src/iosMain/kotlin/io/sellmair/pacemaker/launchVibrationActor.apple.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.evas.value 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.delay 6 | import kotlinx.coroutines.isActive 7 | import kotlinx.coroutines.launch 8 | import platform.UIKit.UIImpactFeedbackGenerator 9 | import platform.UIKit.UIImpactFeedbackStyle.UIImpactFeedbackStyleHeavy 10 | import kotlin.time.Duration.Companion.seconds 11 | 12 | internal fun CoroutineScope.launchVibrationWarningActor() = launch { 13 | while (isActive) { 14 | delay(1.seconds) 15 | if (CriticalGroupState.value() != null && UtteranceState.value() >= UtteranceState.Warnings) { 16 | UIImpactFeedbackGenerator(UIImpactFeedbackStyleHeavy).impactOccurredWithIntensity(1.0) 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/HeartRateSensorServiceDescriptors.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.bluetooth 2 | 3 | import io.sellmair.pacemaker.ble.BleCharacteristicDescriptor 4 | import io.sellmair.pacemaker.ble.BleServiceDescriptor 5 | import io.sellmair.pacemaker.ble.BleUUID 6 | 7 | object HeartRateSensorServiceDescriptors { 8 | val heartRateCharacteristic = BleCharacteristicDescriptor( 9 | name = "Heart Rate", 10 | uuid = BleUUID("00002a37-0000-1000-8000-00805f9b34fb"), 11 | isReadable = false, 12 | isNotificationsEnabled = true 13 | ) 14 | 15 | val service = BleServiceDescriptor( 16 | name = "Heart Rate Service", 17 | uuid = BleUUID("0000180D-0000-1000-8000-00805f9b34fb"), 18 | characteristics = setOf(heartRateCharacteristic) 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /app/src/iosMain/kotlin/IosPacemakerViewController.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui 2 | 3 | import androidx.compose.runtime.CompositionLocalProvider 4 | import androidx.compose.ui.window.ComposeUIViewController 5 | import io.sellmair.evas.compose.LocalEvents 6 | import io.sellmair.evas.compose.LocalStates 7 | import io.sellmair.pacemaker.LocalSessionService 8 | import io.sellmair.pacemaker.backend 9 | 10 | 11 | @Suppress("Unused") // Entry point for iOS application! 12 | object IosPacemakerViewController { 13 | fun create() = ComposeUIViewController { 14 | CompositionLocalProvider( 15 | LocalEvents provides backend.events, 16 | LocalStates provides backend.states, 17 | LocalSessionService provides backend.sessionService 18 | ) { 19 | ApplicationWindow() 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /models/src/commonMain/kotlin/io/sellmair/pacemaker/model/User.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.model 2 | 3 | import kotlin.math.absoluteValue 4 | 5 | data class User( 6 | val id: UserId, 7 | val name: String, 8 | val isAdhoc: Boolean = false, 9 | ) 10 | 11 | val User.nameAbbreviation: String 12 | get() { 13 | if (name.isBlank()) return "" 14 | val parts = name.split(Regex("\\s")) 15 | .filter { it.isNotBlank() } 16 | 17 | if (parts.size >= 2) { 18 | return (parts.first().firstOrNull() ?: "").toString().uppercase() + 19 | (parts.last().firstOrNull() ?: "").toString().uppercase() 20 | } 21 | 22 | return name.take(2) 23 | } 24 | 25 | 26 | fun newUser(id: UserId): User { 27 | return User(id = id, name = "Anonymous ${(id.value % 100).absoluteValue}") 28 | } -------------------------------------------------------------------------------- /app/src/commonMain/composeResources/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Please stand by 👍 3 | Heart Rate Sensor 4 | connect 5 | disconnect 6 | 7 | Nearby Heart Rate Sensors 8 | Searching for heart rate sensors 9 | 10 | Your heart rate is at: %1$d. The current limit is: %2$d 11 | 12 | Slow down! 13 | You are at %1$d bpm 14 | %1$s is at %2$d bpm 15 | 16 | -------------------------------------------------------------------------------- /app/src/commonMain/composeResources/values-de/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Bitte warten Sie 👍 3 | Herzfrequenz Sensor 4 | verbinden 5 | trennen 6 | 7 | Herzfrequenzsensoren in der Nähe 8 | Suche nach Herzfrequenzsensoren 9 | 10 | Deine Herzfrequenz beträgt: %1$d. Dein aktuelles Limit beträgt: %2$d 11 | 12 | Langsamer! 13 | Deine eigene Herzfrequenz beträgt %1$d BPM 14 | %1$s läuft mit %2$d BPM 15 | 16 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/withHeartRateSensor.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.bluetooth.HeartRateSensor 4 | import io.sellmair.pacemaker.bluetooth.HeartRateSensorBluetoothService 5 | import io.sellmair.pacemaker.bluetooth.toHeartRateSensorId 6 | import io.sellmair.pacemaker.model.HeartRateSensorId 7 | import kotlinx.coroutines.flow.collectLatest 8 | import kotlinx.coroutines.flow.distinctUntilChangedBy 9 | import kotlinx.coroutines.flow.map 10 | 11 | internal suspend fun HeartRateSensorBluetoothService.withHeartRateSensor( 12 | id: HeartRateSensorId, block: suspend (sensor: HeartRateSensor?) -> Unit 13 | ) { 14 | allSensorsNearby 15 | .map { sensors -> sensors.find { it.deviceId.toHeartRateSensorId() == id } } 16 | .distinctUntilChangedBy { sensor -> sensor?.deviceId } 17 | .collectLatest { sensorOrNull -> block(sensorOrNull) } 18 | } 19 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/UtteranceState.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.evas.State 4 | import io.sellmair.evas.value 5 | 6 | enum class UtteranceState : State { 7 | Silence, Warnings, All; 8 | 9 | fun next(): UtteranceState { 10 | return UtteranceState.entries[(ordinal + 1) % UtteranceState.entries.size] 11 | } 12 | 13 | companion object Key : State.Key { 14 | override val default: UtteranceState = All 15 | 16 | suspend fun shouldBeAnnounced(type: UtteranceEvent.Type): Boolean { 17 | return when (type) { 18 | UtteranceEvent.Type.Info -> UtteranceState.value() >= All 19 | UtteranceEvent.Type.Warning -> UtteranceState.value() >= Warnings 20 | } 21 | } 22 | 23 | suspend fun shouldBeAnnounced(event: UtteranceEvent) = shouldBeAnnounced(event.type) 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /app/src/commonMain/composeResources/values-it/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Attendere prego 👍 3 | Sensore di frequenza cardiaca 4 | connetti 5 | disconnetti 6 | 7 | Sensori di frequenza cardiaca nelle vicinanze 8 | Ricerca sensori di frequenza cardiaca 9 | 10 | La tua frequenza cardiaca è di: %1$d. Il limite attuale è: %2$d 11 | 12 | Rallentare! 13 | La tua frequenza cardiaca è di %1$d BPM 14 | %1$s sta correndo alla frequenza di %2$d BPM 15 | -------------------------------------------------------------------------------- /bluetooth-core/src/androidMain/kotlin/io/sellmair/pacemaker/ble/AndroidBluetoothExtensions.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import android.Manifest.permission.* 4 | import android.bluetooth.BluetoothDevice 5 | import android.bluetooth.BluetoothManager 6 | import android.content.Context 7 | import android.content.pm.PackageManager 8 | import androidx.core.content.getSystemService 9 | 10 | internal val BluetoothDevice.deviceId: BleDeviceId get() = BleDeviceId(address) 11 | 12 | fun Context.isBluetoothPermissionGranted(): Boolean { 13 | return checkSelfPermission(BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED && 14 | checkSelfPermission(BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED && 15 | checkSelfPermission(BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED 16 | } 17 | 18 | fun Context.isBluetoothEnabled(): Boolean { 19 | return getSystemService()?.adapter?.isEnabled == true 20 | } -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/CriticalGroupState.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.evas.State 4 | import io.sellmair.evas.collect 5 | import io.sellmair.evas.launchState 6 | import kotlinx.coroutines.CoroutineScope 7 | 8 | data class CriticalGroupState(val criticalMembers: List) : State { 9 | companion object : State.Key { 10 | override val default: CriticalGroupState? = null 11 | } 12 | } 13 | 14 | internal fun CoroutineScope.launchCriticalGroupStateActor() = launchState(CriticalGroupState) { 15 | GroupState.collect { groupState -> 16 | val criticalMembers = groupState.members.filter { userState -> 17 | val heartRateLimit = userState.heartRateLimit ?: return@filter false 18 | userState.heartRate > heartRateLimit 19 | } 20 | 21 | (if (criticalMembers.isNotEmpty()) CriticalGroupState(criticalMembers) else null).emit() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchUtteranceSettingsActor.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import com.russhwolf.settings.Settings 4 | import com.russhwolf.settings.set 5 | import io.sellmair.evas.flow 6 | import io.sellmair.evas.set 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.flow.collectLatest 9 | import kotlinx.coroutines.launch 10 | 11 | internal fun CoroutineScope.launchUtteranceSettingsActor(settings: Settings) = launch { 12 | val storedUtterance = settings.getStringOrNull(UtteranceState::class.qualifiedName!!) 13 | if (storedUtterance != null) { 14 | val resolvedStoredUtterance = UtteranceState.entries.find { state -> state.name == storedUtterance } 15 | if (resolvedStoredUtterance != null) { 16 | UtteranceState.set(resolvedStoredUtterance) 17 | } 18 | } 19 | 20 | UtteranceState.flow().collectLatest { state -> 21 | settings[UtteranceState::class.qualifiedName!!] = state.name 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/AppleCentralHardware.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.flow.first 5 | import platform.CoreBluetooth.CBCentralManager 6 | import platform.CoreBluetooth.CBCentralManagerStatePoweredOn 7 | 8 | internal class AppleCentralHardware( 9 | val manager: CBCentralManager, 10 | val delegate: AppleCentralManagerDelegate, 11 | val serviceDescriptor: BleServiceDescriptor 12 | ) 13 | 14 | internal suspend fun AppleCentralHardware( 15 | scope: CoroutineScope, 16 | serviceDescriptor: BleServiceDescriptor 17 | ): AppleCentralHardware { 18 | val delegate = AppleCentralManagerDelegate(scope) 19 | val manager = CBCentralManager(delegate, null) 20 | delegate.state.first { it == CBCentralManagerStatePoweredOn } 21 | return AppleCentralHardware( 22 | manager = manager, 23 | delegate = delegate, 24 | serviceDescriptor = serviceDescriptor 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchHeartRateSensorMeasurement.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("OPT_IN_USAGE") 2 | @file:OptIn(ExperimentalCoroutinesApi::class) 3 | 4 | package io.sellmair.pacemaker 5 | 6 | import io.sellmair.pacemaker.bluetooth.HeartRateSensorBluetoothService 7 | import io.sellmair.pacemaker.bluetooth.toEvent 8 | import io.sellmair.evas.emit 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.Deferred 11 | import kotlinx.coroutines.ExperimentalCoroutinesApi 12 | import kotlinx.coroutines.flow.flatMapMerge 13 | import kotlinx.coroutines.launch 14 | 15 | internal fun CoroutineScope.launchHeartRateSensorMeasurement( 16 | heartRateSensorBluetoothService: Deferred 17 | ) = launch { 18 | /* Connecting our hr receiver and emit hr measurement events */ 19 | heartRateSensorBluetoothService.await().newSensorsNearby 20 | .flatMapMerge { sensor -> sensor.heartRate } 21 | .collect { measurement -> measurement.toEvent().emit() } 22 | } 23 | 24 | -------------------------------------------------------------------------------- /bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleConnectableController.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import kotlinx.coroutines.flow.SharedFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | 6 | interface BleConnectableController { 7 | 8 | val deviceName: String? 9 | 10 | val deviceId: BleDeviceId 11 | 12 | val values: SharedFlow 13 | 14 | val rssi: SharedFlow 15 | 16 | val isConnected: StateFlow 17 | 18 | suspend fun connect(): BleUnit 19 | 20 | suspend fun disconnect(): BleUnit 21 | 22 | suspend fun discoverService(): BleResult> 23 | 24 | suspend fun discoverCharacteristics(): BleUnit 25 | 26 | suspend fun enableNotification(characteristicDescriptor: BleCharacteristicDescriptor): BleResult 27 | 28 | suspend fun readValue(characteristicDescriptor: BleCharacteristicDescriptor): BleResult 29 | 30 | suspend fun writeValue(characteristicDescriptor: BleCharacteristicDescriptor, value: ByteArray): BleResult 31 | 32 | } -------------------------------------------------------------------------------- /app/src/androidMain/kotlin/Previews.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.runtime.Composable 2 | import androidx.compose.ui.tooling.preview.Preview 3 | import io.sellmair.pacemaker.ble.BleConnectable 4 | import io.sellmair.pacemaker.model.HeartRate 5 | import io.sellmair.pacemaker.model.HeartRateSensorId 6 | import io.sellmair.pacemaker.model.User 7 | import io.sellmair.pacemaker.model.UserId 8 | import io.sellmair.pacemaker.ui.settingsPage.HeartRateSensorCard 9 | 10 | @Preview 11 | @Composable 12 | fun HeartRateSensorPreview() { 13 | HeartRateSensorCard( 14 | me = User( 15 | id = UserId(0), 16 | name = "Sebastian Sellmair", 17 | isAdhoc = false, 18 | ), 19 | sensorName = "Polar H10 8B18CA22", 20 | sensorId = HeartRateSensorId("this is a sensor id"), 21 | rssi = 80, 22 | heartRate = HeartRate(64f), 23 | associatedUser = null, 24 | associatedHeartRateLimit = null, 25 | connectIfPossible = false, 26 | connectionState = BleConnectable.ConnectionState.Disconnected 27 | ) 28 | } -------------------------------------------------------------------------------- /models/src/commonMain/kotlin/io/sellmair/pacemaker/model/HeartRate.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.model 2 | 3 | import okio.Buffer 4 | import kotlin.jvm.JvmInline 5 | import kotlin.math.roundToInt 6 | 7 | @JvmInline 8 | value class HeartRate(val value: Float) : Comparable { 9 | constructor(value: Int) : this(value.toFloat()) 10 | 11 | override fun compareTo(other: HeartRate): Int { 12 | return this.value.compareTo(other.value) 13 | } 14 | 15 | override fun toString(): String { 16 | return value.roundToInt().toString() 17 | } 18 | } 19 | 20 | fun HeartRate.encodeToByteArray(): ByteArray { 21 | return Buffer().writeInt(value.toBits()).readByteArray() 22 | } 23 | 24 | fun HeartRate(data: ByteArray): HeartRate? { 25 | val bits = runCatching { 26 | Buffer().write(data).readInt() 27 | }.getOrNull() ?: return null 28 | 29 | return HeartRate(Float.fromBits(bits)) 30 | } 31 | 32 | infix fun ClosedRange.step(n: Int): IntProgression { 33 | return (start.value.roundToInt()..endInclusive.value.roundToInt()) step n 34 | } 35 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchPacemakerBroadcastReceiver.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.bluetooth.PacemakerBluetoothService 4 | import io.sellmair.pacemaker.bluetooth.PacemakerBroadcastPackageEvent 5 | import io.sellmair.pacemaker.bluetooth.broadcastPackages 6 | import io.sellmair.pacemaker.model.User 7 | import io.sellmair.evas.emit 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.Deferred 10 | import kotlinx.coroutines.flow.conflate 11 | import kotlinx.coroutines.launch 12 | 13 | internal fun CoroutineScope.launchPacemakerBroadcastReceiver( 14 | userService: UserService, pacemakerBluetoothService: Deferred 15 | ) = launch { 16 | pacemakerBluetoothService.await().broadcastPackages().conflate().collect { received -> 17 | val user = User(id = received.userId, name = received.userName) 18 | userService.saveUser(user) 19 | userService.saveHeartRateLimit(user, received.heartRateLimit) 20 | PacemakerBroadcastPackageEvent(received).emit() 21 | } 22 | } -------------------------------------------------------------------------------- /utils/src/commonMain/kotlin/io/sellmair/pacemaker/utils/Configuration.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.utils 2 | 3 | import kotlinx.coroutines.currentCoroutineContext 4 | import kotlin.coroutines.CoroutineContext 5 | 6 | suspend fun ConfigurationKey.value(): T? { 7 | currentCoroutineContext()[this]?.let { return it.value } 8 | if (this is ConfigurationKey.WithDefault) return default 9 | return null 10 | } 11 | 12 | suspend fun ConfigurationKey.WithDefault.value(): T { 13 | currentCoroutineContext()[this]?.let { return it.value } 14 | return default 15 | } 16 | 17 | operator fun ConfigurationKey.invoke(value: T): ConfigurationElement { 18 | return ConfigurationElement(this, value) 19 | } 20 | 21 | 22 | data class ConfigurationElement( 23 | override val key: ConfigurationKey, val value: T 24 | ) : CoroutineContext.Element 25 | 26 | 27 | interface ConfigurationKey : CoroutineContext.Key> { 28 | interface WithDefault : ConfigurationKey { 29 | val default: T 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app-core/src/androidMain/kotlin/io/sellmair/pacemaker/launchVibrationActor.android.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import android.content.Context 4 | import android.os.CombinedVibration 5 | import android.os.VibrationEffect 6 | import android.os.VibratorManager 7 | import androidx.core.content.getSystemService 8 | import io.sellmair.evas.value 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.delay 11 | import kotlinx.coroutines.isActive 12 | import kotlinx.coroutines.launch 13 | import kotlin.time.Duration.Companion.seconds 14 | 15 | internal fun CoroutineScope.launchVibrationWarningActor(context: Context) = launch { 16 | val vibratorManager = context.getSystemService() ?: return@launch 17 | while (isActive) { 18 | delay(1.seconds) 19 | if (CriticalGroupState.value() != null && UtteranceState.value() >= UtteranceState.Warnings) { 20 | vibratorManager.vibrate( 21 | CombinedVibration.createParallel( 22 | VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE) 23 | ) 24 | ) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BlePeripheralController.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import kotlinx.coroutines.channels.ReceiveChannel 4 | 5 | internal interface BlePeripheralController { 6 | 7 | fun startAdvertising() 8 | 9 | suspend fun respond(request: WriteRequest, statusCode: BleStatusCode): Boolean 10 | 11 | suspend fun respond(request: ReadRequest, statusCode: BleStatusCode): Boolean 12 | 13 | suspend fun respond( 14 | request: ReadRequest, value: ByteArray, statusCode: BleStatusCode = BleKnownStatusCode.Success 15 | ): Boolean 16 | 17 | suspend fun sendNotification(characteristic: BleCharacteristicDescriptor, value: ByteArray) 18 | 19 | 20 | val writeRequests: ReceiveChannel 21 | 22 | val readRequests: ReceiveChannel 23 | 24 | interface WriteRequest { 25 | val deviceId: BleDeviceId 26 | val characteristicUuid: BleUUID 27 | val value: ByteArray? 28 | } 29 | 30 | interface ReadRequest { 31 | val deviceId: BleDeviceId 32 | val characteristicUuid: BleUUID 33 | val offset: Int 34 | } 35 | } -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchUpdateMeActor.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.model.HeartRate 4 | import io.sellmair.pacemaker.model.User 5 | import io.sellmair.evas.Event 6 | import io.sellmair.evas.collectEvents 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.Job 10 | import kotlinx.coroutines.launch 11 | 12 | 13 | sealed class UpdateMeIntent : Event { 14 | data class UpdateMe(val me: User): UpdateMeIntent() 15 | data class UpdateHeartRateLimit(val heartRateLimit: HeartRate): UpdateMeIntent() 16 | } 17 | 18 | internal fun CoroutineScope.launchUpdateMeActor(userService: UserService): Job = launch(Dispatchers.Main.immediate) { 19 | var me = userService.me() 20 | collectEvents { intent -> 21 | when(intent) { 22 | is UpdateMeIntent.UpdateHeartRateLimit -> userService.saveHeartRateLimit(me, intent.heartRateLimit) 23 | is UpdateMeIntent.UpdateMe -> { 24 | me = intent.me 25 | userService.saveUser(intent.me) 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /utils/src/commonMain/kotlin/io/sellmair/pacemaker/utils/Logging.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.utils 2 | 3 | import kotlin.reflect.KClass 4 | 5 | data class LogTag(val name: String) { 6 | fun with(additional: String) = LogTag("$name | $additional") 7 | fun forClass(clazz: KClass<*>) = with(clazz.simpleName.orEmpty()) 8 | inline fun forClass() = forClass(T::class) 9 | 10 | override fun toString(): String { 11 | return name 12 | } 13 | 14 | companion object 15 | } 16 | 17 | enum class LogLevel { 18 | Debug, Info, Warn, Error 19 | } 20 | 21 | expect object Log { 22 | operator fun invoke(tag: LogTag, level: LogLevel, message: String, throwable: Throwable? = null) 23 | } 24 | 25 | fun LogTag.debug(message: String, throwable: Throwable? = null) = Log(this, LogLevel.Debug, message, throwable) 26 | fun LogTag.info(message: String, throwable: Throwable? = null) = Log(this, LogLevel.Info, message, throwable) 27 | fun LogTag.warn(message: String, throwable: Throwable? = null) = Log(this, LogLevel.Warn, message, throwable) 28 | fun LogTag.error(message: String, throwable: Throwable? = null) = Log(this, LogLevel.Error, message, throwable) 29 | 30 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchHeartRateSensorLinkingActor.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.model.HeartRateSensorId 4 | import io.sellmair.pacemaker.model.User 5 | import io.sellmair.evas.Event 6 | import io.sellmair.evas.collectEvents 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.Job 10 | import kotlinx.coroutines.launch 11 | 12 | sealed interface HeartRateSensorLinkingIntent : Event { 13 | data class LinkSensor(val user: User, val sensor: HeartRateSensorId) : HeartRateSensorLinkingIntent 14 | data class UnlinkSensor(val sensor: HeartRateSensorId) : HeartRateSensorLinkingIntent 15 | } 16 | 17 | 18 | internal fun CoroutineScope.launchHeartRateSensorLinkingActor(userService: UserService): Job = launch(Dispatchers.Main.immediate) { 19 | collectEvents { intent -> 20 | when (intent) { 21 | is HeartRateSensorLinkingIntent.LinkSensor -> userService.linkSensor(intent.user, intent.sensor) 22 | is HeartRateSensorLinkingIntent.UnlinkSensor -> userService.unlinkSensor(intent.sensor) 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /utils/src/commonTest/kotlin/io/sellmair/pacemaker/utils/ConfigurationTest.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.utils 2 | 3 | import kotlinx.coroutines.test.runTest 4 | import kotlinx.coroutines.withContext 5 | import kotlin.test.* 6 | 7 | class ConfigurationTest { 8 | 9 | object StringKey : ConfigurationKey 10 | 11 | object StringKeyWithDefault : ConfigurationKey.WithDefault { 12 | override val default: String = "foo" 13 | } 14 | 15 | @Test 16 | fun `test - no value attached`() = runTest { 17 | assertNull(StringKey.value()) 18 | } 19 | 20 | @Test 21 | fun `test - attach value`() = runTest { 22 | assertNull(StringKey.value()) 23 | 24 | withContext(StringKey("foo")) { 25 | assertEquals("foo", StringKey.value()) 26 | } 27 | 28 | assertNull(StringKey.value()) 29 | } 30 | 31 | @Test 32 | fun `test - attach value - with default`() = runTest { 33 | assertEquals("foo", StringKeyWithDefault.value()) 34 | 35 | withContext(StringKey("bar")) { 36 | assertEquals("bar", StringKey.value()) 37 | } 38 | 39 | assertEquals("foo", StringKeyWithDefault.value()) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/SqlActiveSessionService.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.model.HeartRate 4 | import io.sellmair.pacemaker.model.Session 5 | import io.sellmair.pacemaker.model.User 6 | import io.sellmair.pacemaker.utils.value 7 | import kotlin.time.Instant 8 | 9 | internal class SqlActiveSessionService( 10 | override val session: Session, 11 | private val database: SafePacemakerDatabase 12 | ) : ActiveSessionService { 13 | 14 | override suspend fun stop(): Unit = database { 15 | sessionQueries.endSession(SessionService.SessionClock.value().now().toString(), session.id.value) 16 | } 17 | 18 | override suspend fun save( 19 | user: User, 20 | heartRate: HeartRate, 21 | heartRateLimit: HeartRate?, 22 | measurementTime: Instant 23 | ): Unit = database { 24 | sessionQueries.saveHeartRateMeasurement( 25 | session_id = session.id.value, 26 | user_id = user.id.value, 27 | time = measurementTime.toString(), 28 | heart_rate = heartRate.value.toDouble(), 29 | heart_rate_limit = heartRateLimit?.value?.toDouble() 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/SessionsState.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.model.Session 4 | import io.sellmair.evas.State 5 | import io.sellmair.evas.StateProducerStarted 6 | import io.sellmair.evas.launchState 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Job 9 | import kotlinx.coroutines.flow.SharingStarted.Companion.Lazily 10 | import kotlinx.coroutines.flow.distinctUntilChanged 11 | import kotlinx.coroutines.flow.emitAll 12 | import kotlinx.coroutines.flow.map 13 | 14 | data class SessionsState(val sessions: List) : State { 15 | companion object Key : State.Key { 16 | override val default: SessionsState = SessionsState(emptyList()) 17 | } 18 | } 19 | 20 | internal fun CoroutineScope.launchSessionStateActor(sessionService: SessionService): Job = launchState( 21 | SessionsState.Key, started = StateProducerStarted.Lazily 22 | ) { 23 | val sessionsStateFlow = sessionService.sessionsFlow 24 | .map { sessionServices -> sessionServices.map { it.session }.sortedBy { it.startTime } } 25 | .map { sessions -> SessionsState(sessions) } 26 | .distinctUntilChanged() 27 | 28 | emitAll(sessionsStateFlow) 29 | } 30 | -------------------------------------------------------------------------------- /bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerBluetoothConnection.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("FunctionName") 2 | 3 | package io.sellmair.pacemaker.bluetooth 4 | 5 | import io.sellmair.pacemaker.ble.BleConnection 6 | import io.sellmair.pacemaker.ble.BleDeviceId 7 | import io.sellmair.pacemaker.ble.BleReceivedValue 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.flow.SharedFlow 10 | import kotlin.coroutines.CoroutineContext 11 | 12 | interface PacemakerBluetoothConnection : CoroutineScope { 13 | val deviceId: BleDeviceId 14 | val receivedValues: SharedFlow 15 | } 16 | 17 | internal interface WritablePacemakerBluetoothConnection : PacemakerBluetoothConnection, PacemakerBluetoothWritable 18 | 19 | internal fun PacemakerConnection(connection: BleConnection): WritablePacemakerBluetoothConnection = 20 | object : WritablePacemakerBluetoothConnection, 21 | PacemakerBluetoothWritable by PacemakerBluetoothWritable(connection) { 22 | override val deviceId: BleDeviceId = connection.deviceId 23 | override val receivedValues: SharedFlow = connection.receivedValues 24 | override val coroutineContext: CoroutineContext = connection.scope.coroutineContext 25 | } 26 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/ColorHueSlider.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.widget 2 | 3 | import androidx.compose.material3.Slider 4 | import androidx.compose.material3.SliderDefaults 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import io.sellmair.evas.compose.EvasLaunching 8 | import io.sellmair.evas.compose.composeValue 9 | import io.sellmair.evas.emitAsync 10 | import io.sellmair.pacemaker.MeColorIntent 11 | import io.sellmair.pacemaker.MeColorState 12 | import io.sellmair.pacemaker.UserColors 13 | import io.sellmair.pacemaker.ui.toColor 14 | 15 | @Composable 16 | fun ColorHueSlider( 17 | modifier: Modifier = Modifier 18 | ) { 19 | val meColor = MeColorState.composeValue()?.color ?: return 20 | 21 | Slider( 22 | valueRange = 0f..360f, 23 | value = meColor.hue, 24 | onValueChange = EvasLaunching { newHue -> 25 | MeColorIntent.ChangeHue(newHue).emitAsync() 26 | }, 27 | modifier = modifier, 28 | colors = SliderDefaults.colors( 29 | thumbColor = meColor.toColor(), 30 | activeTrackColor = UserColors.fromHueLight(meColor.hue).toColor(), 31 | inactiveTrackColor = meColor.toColor() 32 | ) 33 | ) 34 | } -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/colorUtils.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.model.Hue 4 | import io.sellmair.pacemaker.model.UserId 5 | import kotlin.math.absoluteValue 6 | 7 | object UserColors { 8 | const val saturation = .5f 9 | const val lightness = .4f 10 | const val saturationLight = .7f 11 | const val lightnessLight = .75f 12 | 13 | 14 | fun default(userId: UserId?): HSLColor = fromHue(defaultHue(userId)) 15 | 16 | fun defaultHue(userId: UserId?): Float = userId.hashCode().toFloat().absoluteValue % 360f 17 | 18 | fun fromHue(hue: Hue): HSLColor = fromHue(hue.value) 19 | 20 | fun fromHue(hue: Float) = HSLColor( 21 | hue = hue, saturation = saturation, lightness = lightness 22 | ) 23 | 24 | fun fromHueLight(hue: Float) = HSLColor( 25 | hue = hue, saturation = saturationLight, lightness = lightnessLight 26 | ) 27 | } 28 | 29 | fun HSLColor.toUserLight() = UserColors.fromHueLight(hue) 30 | 31 | data class HSLColor( 32 | val hue: Float, 33 | val saturation: Float, 34 | val lightness: Float 35 | ) 36 | 37 | 38 | val UserState.displayColor: HSLColor 39 | get() = color 40 | 41 | val UserState.displayColorLight: HSLColor 42 | get() = color.toUserLight() 43 | 44 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/UserService.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.model.HeartRate 4 | import io.sellmair.pacemaker.model.HeartRateSensorId 5 | import io.sellmair.pacemaker.model.User 6 | import io.sellmair.pacemaker.model.UserId 7 | import io.sellmair.pacemaker.utils.ConfigurationKey 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | interface UserService { 11 | suspend fun me(): User 12 | suspend fun saveUser(user: User) 13 | suspend fun deleteUser(user: User) 14 | 15 | suspend fun findUser(userId: UserId): User? 16 | suspend fun findUser(sensorId: HeartRateSensorId): User? 17 | fun findUserFlow(sensorId: HeartRateSensorId): Flow 18 | 19 | suspend fun linkSensor(user: User, sensorId: HeartRateSensorId) 20 | suspend fun unlinkSensor(sensorId: HeartRateSensorId) 21 | 22 | suspend fun findHeartRateLimit(user: User): HeartRate? 23 | fun findHeartRateLimitFlow(user: User): Flow 24 | suspend fun saveHeartRateLimit(user: User, limit: HeartRate) 25 | 26 | val onChange: Flow 27 | val onSaveUser: Flow 28 | 29 | object NewUserHeartRateLimit : ConfigurationKey.WithDefault { 30 | override val default: HeartRate = HeartRate(145) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/coroutineUtils.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.widget 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.rememberCoroutineScope 5 | import io.sellmair.evas.compose.eventsOrNull 6 | import io.sellmair.evas.compose.statesOrNull 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.launch 10 | import kotlin.coroutines.EmptyCoroutineContext 11 | 12 | @Composable 13 | fun Launching(action: suspend () -> Unit): () -> Unit { 14 | val scope = rememberPacemakerCoroutineScope() 15 | return { 16 | scope.launch { 17 | action() 18 | } 19 | } 20 | } 21 | 22 | 23 | @Composable 24 | fun Launching(action: suspend (value: T) -> Unit): (T) -> Unit { 25 | val scope = rememberPacemakerCoroutineScope() 26 | return { value -> 27 | scope.launch { 28 | action(value) 29 | } 30 | } 31 | } 32 | 33 | 34 | @Composable 35 | fun rememberPacemakerCoroutineScope(): CoroutineScope { 36 | val eventBus = eventsOrNull() ?: EmptyCoroutineContext 37 | val stateBus = statesOrNull() ?: EmptyCoroutineContext 38 | return rememberCoroutineScope { Dispatchers.Main.immediate + eventBus + stateBus } 39 | } 40 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/SessionService.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import androidx.compose.runtime.staticCompositionLocalOf 4 | import io.sellmair.pacemaker.model.HeartRate 5 | import io.sellmair.pacemaker.model.Session 6 | import io.sellmair.pacemaker.model.User 7 | import io.sellmair.pacemaker.utils.ConfigurationKey 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlin.time.Clock 10 | import kotlin.time.Instant 11 | 12 | val LocalSessionService = staticCompositionLocalOf { null } 13 | 14 | interface SessionService { 15 | object SessionClock : ConfigurationKey.WithDefault { 16 | override val default: Clock = Clock.System 17 | } 18 | 19 | suspend fun createSession(): ActiveSessionService 20 | suspend fun getSessions(): List 21 | 22 | val sessionsFlow: Flow> 23 | } 24 | 25 | interface ActiveSessionService { 26 | val session: Session 27 | suspend fun stop() 28 | suspend fun save( 29 | user: User, heartRate: HeartRate, heartRateLimit: HeartRate?, measurementTime: Instant, 30 | ) 31 | } 32 | 33 | interface StoredSessionService { 34 | val session: Session 35 | suspend fun getUsers(): List 36 | suspend fun getHeartRateMeasurements(user: User): List 37 | } 38 | -------------------------------------------------------------------------------- /app-core/src/sql/io/sellmair/pacemaker/Session.sq: -------------------------------------------------------------------------------- 1 | CREATE TABLE db_session ( 2 | id INTEGER PRIMARY KEY NOT NULL, 3 | start_time TEXT NOT NULL, 4 | end_time TEXT 5 | ); 6 | 7 | CREATE TABLE db_session_heart_rate( 8 | session_id INTEGER NOT NULL, 9 | user_id INTEGER NOT NULL, 10 | time TEXT NOT NULL, 11 | heart_rate REAL NOT NULL, 12 | heart_rate_limit REAL, 13 | FOREIGN KEY (user_id) REFERENCES db_user(id), 14 | FOREIGN KEY (session_id) REFERENCES db_session(id) 15 | ); 16 | 17 | CREATE INDEX db_heart_rate_measurement_session_index ON db_session_heart_rate(session_id); 18 | 19 | CREATE INDEX db_heart_rate_measurement_user_index ON db_session_heart_rate(user_id); 20 | 21 | newSession: 22 | INSERT INTO db_session(start_time) VALUES (?); 23 | 24 | lastSessionId: 25 | SELECT last_insert_rowid(); 26 | 27 | endSession: 28 | UPDATE db_session SET end_time=? WHERE id =?; 29 | 30 | allSessions: 31 | SELECT * FROM db_session; 32 | 33 | saveHeartRateMeasurement: 34 | INSERT INTO db_session_heart_rate( 35 | session_id, 36 | user_id, 37 | time, 38 | heart_rate, 39 | heart_rate_limit 40 | ) VALUES (?, ?, ?, ?, ?); 41 | 42 | findHeartRateMeasurements: 43 | SELECT * FROM db_session_heart_rate WHERE session_id=?; 44 | 45 | findUsers: 46 | SELECT DISTINCT (user_id) FROM db_session_heart_rate WHERE session_id=?; -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | pluginManagement { 4 | repositories { 5 | mavenCentral() 6 | gradlePluginPortal() 7 | google() 8 | maven("https://repo.sellmair.io") 9 | 10 | } 11 | } 12 | 13 | dependencyResolutionManagement { 14 | versionCatalogs.register("deps") { 15 | from(files("dependencies.toml")) 16 | } 17 | } 18 | 19 | dependencyResolutionManagement { 20 | repositories { 21 | mavenCentral() 22 | google { 23 | mavenContent { 24 | includeGroupByRegex(".*android.*") 25 | includeGroupByRegex(".*androidx.*") 26 | } 27 | } 28 | 29 | maven("https://androidx.dev/storage/compose-compiler/repository") { 30 | mavenContent { 31 | includeGroupByRegex("androidx.compose.*") 32 | } 33 | } 34 | 35 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") { 36 | mavenContent { 37 | includeGroupByRegex(".*compose.*") 38 | } 39 | } 40 | 41 | maven("https://repo.sellmair.io") 42 | } 43 | } 44 | 45 | include(":utils") 46 | include(":models") 47 | include(":bluetooth") 48 | include(":bluetooth-core") 49 | include(":spoof-tool") 50 | include(":app") 51 | include(":app-core") 52 | -------------------------------------------------------------------------------- /bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerBluetoothWritableImpl.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.bluetooth 2 | 3 | import io.sellmair.pacemaker.ble.BleWritable 4 | import io.sellmair.pacemaker.model.HeartRate 5 | import io.sellmair.pacemaker.model.Hue 6 | import io.sellmair.pacemaker.model.User 7 | import io.sellmair.pacemaker.model.encodeToByteArray 8 | 9 | 10 | internal fun PacemakerBluetoothWritable(underlying: BleWritable) = object : PacemakerBluetoothWritable { 11 | override suspend fun setUser(user: User) { 12 | underlying.setValue(PacemakerServiceDescriptors.userNameCharacteristic, user.name.encodeToByteArray()) 13 | underlying.setValue(PacemakerServiceDescriptors.userIdCharacteristic, user.id.encodeToByteArray()) 14 | } 15 | 16 | override suspend fun setHeartRate(heartRate: HeartRate) { 17 | underlying.setValue(PacemakerServiceDescriptors.heartRateCharacteristic, heartRate.encodeToByteArray()) 18 | } 19 | 20 | override suspend fun setHeartRateLimit(heartRate: HeartRate) { 21 | underlying.setValue(PacemakerServiceDescriptors.heartRateLimitCharacteristic, heartRate.encodeToByteArray()) 22 | } 23 | 24 | override suspend fun setColorHue(hue: Hue) { 25 | underlying.setValue(PacemakerServiceDescriptors.userColorHueCharacteristic, hue.encodeToByteArray()) 26 | } 27 | } -------------------------------------------------------------------------------- /app-core/src/sql/io/sellmair/pacemaker/User.sq: -------------------------------------------------------------------------------- 1 | CREATE TABLE db_user ( 2 | id INTEGER PRIMARY KEY NOT NULL, 3 | name TEXT NOT NULL, 4 | is_adhoc INTEGER NOT NULL 5 | ); 6 | 7 | CREATE TABLE db_sensor( 8 | id TEXT PRIMARY KEY NOT NULL, 9 | user_id INTEGER NOT NULL, 10 | FOREIGN KEY(user_id) REFERENCES db_user(id) 11 | ); 12 | 13 | CREATE TABLE db_heart_rate_limit( 14 | user_id INTEGER PRIMARY KEY NOT NULL , 15 | heart_rate_limit REAL, 16 | FOREIGN KEY (user_id) REFERENCES db_user(id) 17 | ); 18 | 19 | findUserById: 20 | SELECT * FROM db_user WHERE id = ?; 21 | 22 | findUserBySensorId: 23 | SELECT * FROM db_user 24 | LEFT JOIN db_sensor ON db_sensor.user_id = db_user.id 25 | WHERE db_sensor.id = ?; 26 | 27 | findUserSettingsForUserId: 28 | SELECT * FROM db_heart_rate_limit WHERE user_id = ?; 29 | 30 | saveUser: 31 | INSERT OR REPLACE INTO db_user VALUES ?; 32 | 33 | saveHeartRateLimit: 34 | INSERT OR REPLACE INTO db_heart_rate_limit VALUES ?; 35 | 36 | saveSensor: 37 | INSERT OR REPLACE INTO db_sensor VALUES ?; 38 | 39 | deleteUser: 40 | DELETE FROM db_user WHERE db_user.id = ?; 41 | 42 | deleteHeartRateLimit: 43 | DELETE FROM db_heart_rate_limit WHERE db_heart_rate_limit.user_id = ?; 44 | 45 | deleteUserSensors: 46 | DELETE FROM db_sensor WHERE db_sensor.user_id = ?; 47 | 48 | deleteSensor: 49 | DELETE FROM db_sensor WHERE db_sensor.id = ?; 50 | -------------------------------------------------------------------------------- /bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/BleStatusCode.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | fun BleStatusCode(value: Int): BleStatusCode { 4 | return BleKnownStatusCode.byCode[value] ?: BleUnknownStatusCode(value) 5 | } 6 | 7 | sealed interface BleStatusCode { 8 | val code: Int 9 | } 10 | 11 | val BleStatusCode.isSuccess get() = this == BleKnownStatusCode.Success 12 | fun BleStatusCode.toInt() = code 13 | fun BleStatusCode.toLong() = code.toLong() 14 | 15 | enum class BleKnownStatusCode(override val code: Int) : BleStatusCode { 16 | Success(0), 17 | IllegalOffset(7), 18 | GattError(133), 19 | InternalError(129), 20 | UnknownAttribute(0x010A); 21 | 22 | override fun toString(): String { 23 | return "BleStatusCode($name)" 24 | } 25 | 26 | companion object { 27 | val values = values().toList() 28 | val codes = values.map { it.code }.toSet() 29 | val byCode = values.associateBy { it.code } 30 | } 31 | } 32 | 33 | class BleUnknownStatusCode internal constructor(override val code: Int) : BleStatusCode { 34 | init { 35 | val knownStatusCode = BleKnownStatusCode.byCode[code] 36 | if (knownStatusCode != null) { 37 | throw IllegalArgumentException("code '$code' is known as '$knownStatusCode'") 38 | } 39 | } 40 | 41 | override fun toString(): String { 42 | return "BleStatusCode($code)" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app-core/src/iosMain/kotlin/io/sellmair/pacemaker/launchSpeechSynthesizer.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalForeignApi::class) 2 | 3 | package io.sellmair.pacemaker 4 | 5 | import io.sellmair.evas.events 6 | import kotlinx.cinterop.ExperimentalForeignApi 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.delay 9 | import kotlinx.coroutines.flow.conflate 10 | import kotlinx.coroutines.launch 11 | import platform.AVFAudio.* 12 | import kotlin.time.Duration.Companion.milliseconds 13 | 14 | private val synthesizer = AVSpeechSynthesizer() 15 | 16 | @OptIn(ExperimentalForeignApi::class) 17 | internal fun CoroutineScope.launchSpeechSynthesizer() = launch { 18 | val audioSession = AVAudioSession.sharedInstance() 19 | audioSession.setCategory( 20 | AVAudioSessionCategoryPlayback, 21 | AVAudioSessionCategoryOptionDuckOthers or AVAudioSessionCategoryOptionInterruptSpokenAudioAndMixWithOthers, 22 | null 23 | ) 24 | audioSession.setMode(AVAudioSessionModeVoicePrompt, null) 25 | 26 | events().conflate().collect { event -> 27 | if (!UtteranceState.shouldBeAnnounced(event)) return@collect 28 | val utterance = AVSpeechUtterance.speechUtteranceWithString(event.message) 29 | utterance.rate = AVSpeechUtteranceDefaultSpeechRate 30 | synthesizer.speakUtterance(utterance) 31 | while (synthesizer.isSpeaking()) delay(250.milliseconds) 32 | audioSession.setActive(false, null) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/MeColorState.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import com.russhwolf.settings.Settings 4 | import com.russhwolf.settings.set 5 | import io.sellmair.evas.Event 6 | import io.sellmair.evas.State 7 | import io.sellmair.evas.collectEvents 8 | import io.sellmair.evas.launchState 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.Dispatchers 11 | 12 | class MeColorState(val color: HSLColor) : State { 13 | companion object Key : State.Key { 14 | override val default: MeColorState? = null 15 | } 16 | } 17 | 18 | sealed class MeColorIntent : Event { 19 | data class ChangeHue(val hue: Float) : MeColorIntent() 20 | } 21 | 22 | internal fun CoroutineScope.launchMeColorStateActor( 23 | settings: Settings 24 | ) = launchState(MeColorState, context = Dispatchers.Main.immediate) { 25 | val initialHue = settings.storedUserHue ?: UserColors.defaultHue(settings.meId) 26 | MeColorState(UserColors.fromHue(initialHue)).emit() 27 | 28 | collectEvents { event -> 29 | MeColorState(UserColors.fromHue(event.hue)).emit() 30 | settings.storedUserHue = event.hue 31 | } 32 | } 33 | 34 | private const val hueSettingsKey = "me.hue" 35 | 36 | private var Settings.storedUserHue: Float? 37 | get() = getFloatOrNull(hueSettingsKey) 38 | set(value) { 39 | if (value != null) set(hueSettingsKey, value) 40 | else remove(hueSettingsKey) 41 | } 42 | -------------------------------------------------------------------------------- /app/src/jvmMain/kotlin/io/sellmair/pacemaker/ui/main.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui 2 | 3 | import androidx.compose.material.Text 4 | import androidx.compose.ui.Alignment 5 | import androidx.compose.ui.unit.DpSize 6 | import androidx.compose.ui.unit.dp 7 | import androidx.compose.ui.window.WindowPosition 8 | import androidx.compose.ui.window.WindowState 9 | import androidx.compose.ui.window.singleWindowApplication 10 | import io.sellmair.evas.compose.installEvas 11 | import io.sellmair.evas.eventsOrThrow 12 | import io.sellmair.evas.statesOrThrow 13 | import io.sellmair.pacemaker.JvmApplicationBackend 14 | import io.sellmair.pacemaker.JvmHeartRateSensorBluetoothService 15 | import io.sellmair.pacemaker.launchApplicationBackend 16 | import kotlinx.coroutines.CoroutineScope 17 | import kotlinx.coroutines.Dispatchers 18 | import kotlinx.coroutines.SupervisorJob 19 | 20 | val appScope = CoroutineScope( 21 | SupervisorJob() + Dispatchers.Main + 22 | JvmApplicationBackend.events + JvmApplicationBackend.states 23 | ) 24 | 25 | fun main() { 26 | 27 | JvmApplicationBackend.launchApplicationBackend(appScope) 28 | 29 | 30 | singleWindowApplication( 31 | title = "Pacemaker", 32 | alwaysOnTop = true, 33 | state = WindowState(position = WindowPosition.Aligned(Alignment.TopEnd), size = DpSize(400.dp, 800.dp)), 34 | ) { 35 | installEvas(appScope.coroutineContext.eventsOrThrow, appScope.coroutineContext.statesOrThrow) { 36 | ApplicationWindow() 37 | } 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/AppleBle.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import io.sellmair.pacemaker.ble.impl.BleCentralServiceImpl 4 | import io.sellmair.pacemaker.ble.impl.BlePeripheralServiceImpl 5 | import kotlinx.coroutines.* 6 | 7 | fun AppleBle(): Ble = AppleBleImpl() 8 | 9 | private class AppleBleImpl : Ble { 10 | 11 | override val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) 12 | 13 | private val queue = BleQueue(coroutineScope.coroutineContext.job) 14 | 15 | override suspend fun createCentralService(service: BleServiceDescriptor): BleCentralService { 16 | return withContext(coroutineScope.coroutineContext) { 17 | val centralHardware = AppleCentralHardware(coroutineScope, service) 18 | val controller = AppleCentralController(coroutineScope, centralHardware) 19 | BleCentralServiceImpl(coroutineScope, queue, controller, service) 20 | } 21 | } 22 | 23 | override suspend fun createPeripheralService(service: BleServiceDescriptor): BlePeripheralService { 24 | return withContext(coroutineScope.coroutineContext) { 25 | val peripheralHardware = ApplePeripheralHardware(coroutineScope, service) 26 | val peripheralController = ApplePeripheralController(coroutineScope, peripheralHardware) 27 | BlePeripheralServiceImpl(queue, peripheralController, service, coroutineScope) 28 | } 29 | } 30 | 31 | override fun close() { 32 | coroutineScope.cancel() 33 | } 34 | } 35 | 36 | 37 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/SafePacemakerDatabase.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.sql.PacemakerDatabase 4 | import io.sellmair.pacemaker.utils.ConfigurationKey 5 | import io.sellmair.pacemaker.utils.value 6 | import kotlinx.coroutines.CoroutineDispatcher 7 | import kotlinx.coroutines.DelicateCoroutinesApi 8 | import kotlinx.coroutines.ExperimentalCoroutinesApi 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.emitAll 11 | import kotlinx.coroutines.flow.flowOn 12 | import kotlinx.coroutines.newSingleThreadContext 13 | import kotlinx.coroutines.withContext 14 | 15 | @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) 16 | private val databaseBackgroundThread: CoroutineDispatcher = newSingleThreadContext("database") 17 | 18 | internal object DatabaseBackgroundDispatcher : ConfigurationKey.WithDefault { 19 | override val default: CoroutineDispatcher = databaseBackgroundThread 20 | } 21 | 22 | internal class SafePacemakerDatabase( 23 | factory: () -> PacemakerDatabase 24 | ) { 25 | private val database by lazy(factory) 26 | 27 | suspend operator fun invoke(block: suspend PacemakerDatabase.() -> T): T { 28 | return withContext(DatabaseBackgroundDispatcher.value()) { 29 | block(database) 30 | } 31 | } 32 | 33 | fun flow(block: PacemakerDatabase.() -> Flow): Flow { 34 | return kotlinx.coroutines.flow.flow { 35 | emitAll(database.block().flowOn(DatabaseBackgroundDispatcher.value())) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/OnHeartRateScalePosition.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.widget 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.layout.Layout 6 | import io.sellmair.pacemaker.model.HeartRate 7 | import kotlin.math.roundToInt 8 | 9 | enum class ScaleSide { 10 | Left, Right 11 | } 12 | 13 | @Composable 14 | internal fun OnHeartRateScalePosition( 15 | heartRate: HeartRate, 16 | range: ClosedRange, 17 | modifier: Modifier = Modifier, 18 | horizontalCenterBias: Float = .5f, 19 | side: ScaleSide = ScaleSide.Right, 20 | content: @Composable () -> Unit 21 | ) { 22 | Layout(modifier = modifier, content = content) { measurables, constraints -> 23 | val placeables = measurables.map { measurable -> measurable.measure(constraints) } 24 | layout(constraints.maxWidth, constraints.maxHeight) { 25 | placeables.forEach { placeable -> 26 | placeable.placeRelative( 27 | x = when (side) { 28 | ScaleSide.Right -> constraints.maxWidth.times(horizontalCenterBias).roundToInt() 29 | ScaleSide.Left -> (constraints.maxWidth.times(horizontalCenterBias) - placeable.width).roundToInt() 30 | }, 31 | y = yOfHeartRate( 32 | HeartRate(heartRate.value), range, constraints.maxHeight.toFloat() 33 | ).roundToInt() - placeable.height / 2 34 | ) 35 | } 36 | } 37 | } 38 | } 39 | 40 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/mainPage/GroupHeartRateScale.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.mainPage 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.key 5 | import io.sellmair.evas.compose.composeValue 6 | import io.sellmair.evas.emit 7 | import io.sellmair.pacemaker.GroupState 8 | import io.sellmair.pacemaker.UpdateMeIntent 9 | import io.sellmair.pacemaker.model.HeartRate 10 | import io.sellmair.pacemaker.ui.widget.* 11 | 12 | @Composable 13 | fun GroupHeartRateOverview( 14 | range: ClosedRange = HeartRate(40)..HeartRate(200f), 15 | ) { 16 | GroupHeartRateOverview( 17 | state = GroupState.composeValue(), 18 | range = range 19 | ) 20 | } 21 | 22 | @Composable 23 | internal fun GroupHeartRateOverview( 24 | state: GroupState?, 25 | range: ClosedRange = HeartRate(40)..HeartRate(200f), 26 | ) { 27 | HeartRateScale(range = range) { 28 | state?.members.orEmpty().forEach { memberState -> 29 | key(memberState.user.id.value) { 30 | MemberHeartRateIndicator(memberState, range) 31 | 32 | if (memberState.isMe) { 33 | ChangeableMemberHeartRateLimit( 34 | state = memberState, 35 | range = range, 36 | onLimitChanged = Launching { heartRate -> 37 | UpdateMeIntent.UpdateHeartRateLimit(heartRate).emit() 38 | } 39 | ) 40 | } else { 41 | MemberHeartRateLimit(memberState, range) 42 | } 43 | } 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerBluetoothService.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("OPT_IN_USAGE") 2 | 3 | package io.sellmair.pacemaker.bluetooth 4 | 5 | import io.sellmair.pacemaker.ble.Ble 6 | import kotlinx.coroutines.flow.SharedFlow 7 | import kotlinx.coroutines.flow.SharingStarted 8 | import kotlinx.coroutines.flow.flattenMerge 9 | import kotlinx.coroutines.flow.flowOf 10 | import kotlinx.coroutines.flow.shareIn 11 | 12 | interface PacemakerBluetoothService { 13 | val newConnections: SharedFlow 14 | val allConnections: SharedFlow> 15 | suspend fun write(write: suspend PacemakerBluetoothWritable.() -> Unit) 16 | } 17 | 18 | suspend fun PacemakerBluetoothService(ble: Ble): PacemakerBluetoothService { 19 | val peripheral = PacemakerPeripheralBluetoothService(ble) 20 | val central = PacemakerCentralBluetoothService(ble) 21 | 22 | return object : PacemakerBluetoothService { 23 | override val newConnections: SharedFlow = 24 | flowOf(peripheral.newConnections, central.newConnections).flattenMerge() 25 | .shareIn(ble.coroutineScope, SharingStarted.Eagerly) 26 | 27 | override val allConnections: SharedFlow> = 28 | flowOf(peripheral.allConnections, central.allConnections) 29 | .flattenMerge().shareIn(ble.coroutineScope, SharingStarted.Eagerly) 30 | 31 | override suspend fun write(write: suspend PacemakerBluetoothWritable.() -> Unit) { 32 | peripheral.write(write) 33 | central.write(write) 34 | } 35 | } 36 | } 37 | 38 | -------------------------------------------------------------------------------- /bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/AppleCBMutableServiceFactory.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import platform.CoreBluetooth.CBAttributePermissionsReadable 4 | import platform.CoreBluetooth.CBAttributePermissionsWriteable 5 | import platform.CoreBluetooth.CBCharacteristicPropertyNotify 6 | import platform.CoreBluetooth.CBCharacteristicPropertyRead 7 | import platform.CoreBluetooth.CBCharacteristicPropertyWrite 8 | import platform.CoreBluetooth.CBCharacteristicPropertyWriteWithoutResponse 9 | import platform.CoreBluetooth.CBMutableCharacteristic 10 | import platform.CoreBluetooth.CBMutableService 11 | 12 | 13 | internal fun CBMutableService(descriptor: BleServiceDescriptor): CBMutableService { 14 | val service = CBMutableService(descriptor.uuid, true) 15 | val characteristics = descriptor.characteristics.map { characteristic -> 16 | CBMutableCharacteristic( 17 | type = characteristic.uuid, 18 | properties = ((CBCharacteristicPropertyRead.takeIf { characteristic.isReadable } ?: 0.toULong()) or 19 | (CBCharacteristicPropertyNotify.takeIf { characteristic.isNotificationsEnabled } ?: 0.toULong())) or 20 | (CBCharacteristicPropertyWrite.takeIf { characteristic.isWritable } ?: 0.toULong()) or 21 | (CBCharacteristicPropertyWriteWithoutResponse.takeIf { characteristic.isWritable } ?: 0.toULong()), 22 | value = null, 23 | permissions = CBAttributePermissionsReadable or 24 | (CBAttributePermissionsWriteable.takeIf { characteristic.isWritable } ?: 0.toULong()) 25 | ) 26 | } 27 | service.setCharacteristics(characteristics) 28 | return service 29 | } -------------------------------------------------------------------------------- /.run/🔥app [jvm].run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 18 | 20 | false 21 | true 22 | false 23 | 24 | 25 | $PROJECT_DIR$/app 26 | hotRunJvm 27 | true 28 | 29 | 30 | false 31 | false 32 | false 33 | false 34 | 35 | 36 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/HeartRateSensorsState.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import androidx.compose.runtime.Immutable 4 | import io.sellmair.evas.State 5 | import io.sellmair.evas.launchState 6 | import io.sellmair.pacemaker.bluetooth.HeartRateSensorBluetoothService 7 | import io.sellmair.pacemaker.bluetooth.toHeartRateSensorId 8 | import io.sellmair.pacemaker.model.HeartRateSensorId 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.Deferred 11 | import kotlinx.coroutines.flow.emitAll 12 | import kotlinx.coroutines.flow.map 13 | 14 | @Immutable 15 | data class HeartRateSensorsState(val nearbySensors: List) : State { 16 | @Immutable 17 | data class HeartRateSensorInfo( 18 | val id: HeartRateSensorId, 19 | val name: String? 20 | ) 21 | 22 | companion object Key : State.Key { 23 | override val default: HeartRateSensorsState 24 | get() = HeartRateSensorsState(emptyList()) 25 | } 26 | } 27 | 28 | internal fun CoroutineScope.launchHeartRateSensorsStateActor( 29 | bluetoothService: Deferred 30 | ) = launchState(HeartRateSensorsState) { 31 | emitAll(bluetoothService.await() 32 | .allSensorsNearby 33 | .map { sensors -> 34 | val nearbySensors = sensors 35 | .sortedBy { it.deviceId.value } 36 | .map { sensor -> 37 | HeartRateSensorsState.HeartRateSensorInfo( 38 | id = sensor.deviceId.toHeartRateSensorId(), 39 | name = sensor.deviceName 40 | ) 41 | } 42 | HeartRateSensorsState(nearbySensors) 43 | }) 44 | } -------------------------------------------------------------------------------- /bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/ApplePeripheralHardware.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import io.sellmair.pacemaker.ble.ApplePeripheralHardware.Companion.log 4 | import io.sellmair.pacemaker.utils.LogTag 5 | import io.sellmair.pacemaker.utils.error 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.flow.first 8 | import platform.CoreBluetooth.CBMutableService 9 | import platform.CoreBluetooth.CBPeripheralManager 10 | import platform.CoreBluetooth.CBPeripheralManagerStatePoweredOn 11 | 12 | internal class ApplePeripheralHardware( 13 | val manager: CBPeripheralManager, 14 | val delegate: ApplePeripheralManagerDelegate, 15 | val service: CBMutableService, 16 | val serviceDescriptor: BleServiceDescriptor 17 | ) { 18 | companion object { 19 | val log = LogTag.ble.forClass() 20 | } 21 | } 22 | 23 | internal suspend fun ApplePeripheralHardware( 24 | scope: CoroutineScope, 25 | serviceDescriptor: BleServiceDescriptor 26 | ): ApplePeripheralHardware { 27 | val managerDelegate = ApplePeripheralManagerDelegate(scope) 28 | val manager = CBPeripheralManager(managerDelegate, null) 29 | /* Wait for bluetooth to power on */ 30 | managerDelegate.state.first { it == CBPeripheralManagerStatePoweredOn } 31 | 32 | val service = CBMutableService(serviceDescriptor) 33 | manager.addService(service) 34 | managerDelegate.didAddService.first { it.service == service }.error?.let { error -> 35 | log.error("Failed adding service $serviceDescriptor: ${error.localizedDescription}") 36 | } 37 | 38 | return ApplePeripheralHardware( 39 | manager = manager, delegate = managerDelegate, service = service, serviceDescriptor = serviceDescriptor 40 | ) 41 | } -------------------------------------------------------------------------------- /bluetooth-core/src/androidMain/kotlin/io/sellmair/pacemaker/ble/AndroidBleImpl.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import android.content.Context 4 | import io.sellmair.pacemaker.ble.impl.BleCentralServiceImpl 5 | import io.sellmair.pacemaker.ble.impl.BlePeripheralServiceImpl 6 | import kotlinx.coroutines.* 7 | import java.io.Closeable 8 | 9 | suspend fun AndroidBle(context: Context): Ble { 10 | /* Await permissions to be granted and bluetooth to be turned on*/ 11 | while ( 12 | !context.isBluetoothPermissionGranted() || !context.isBluetoothEnabled() 13 | ) delay(250) 14 | 15 | return AndroidBleImpl(context) 16 | } 17 | 18 | 19 | private class AndroidBleImpl(private val context: Context) : Ble, Closeable { 20 | 21 | @OptIn(ExperimentalCoroutinesApi::class) 22 | override val coroutineScope = CoroutineScope(Dispatchers.Default.limitedParallelism(1) + SupervisorJob()) 23 | 24 | private val queue = BleQueue(coroutineScope.coroutineContext.job) 25 | 26 | override suspend fun createCentralService(service: BleServiceDescriptor): BleCentralService { 27 | val hardware = AndroidCentralHardware(context) 28 | val controller: BleCentralController = AndroidCentralController(coroutineScope, hardware, service) 29 | return BleCentralServiceImpl(coroutineScope, queue, controller, service) 30 | } 31 | 32 | override suspend fun createPeripheralService(service: BleServiceDescriptor): BlePeripheralService { 33 | val hardware = AndroidPeripheralHardware(context, coroutineScope, service) 34 | val controller = AndroidPeripheralController(coroutineScope, hardware) 35 | return BlePeripheralServiceImpl(queue, controller, service, coroutineScope) 36 | } 37 | 38 | override fun close() { 39 | coroutineScope.cancel() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/impl/BleConnectionImpl.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble.impl 2 | 3 | import io.sellmair.pacemaker.ble.* 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.flow.SharedFlow 6 | import kotlinx.coroutines.isActive 7 | 8 | internal class BleConnectionImpl( 9 | override val scope: CoroutineScope, 10 | private val queue: BleQueue, 11 | private val controller: BleConnectableController, 12 | override val service: BleServiceDescriptor 13 | ) : BleConnection { 14 | override val deviceId: BleDeviceId = controller.deviceId 15 | 16 | override val receivedValues: SharedFlow 17 | get() = controller.values 18 | 19 | override suspend fun enableNotifications(characteristic: BleCharacteristicDescriptor): BleResult { 20 | if (!scope.isActive) BleFailure.Rejected 21 | return queue enqueue EnableNotificationsBleOperation(controller.deviceId, characteristic) { 22 | controller.enableNotification(characteristic) 23 | } 24 | } 25 | 26 | override suspend fun requestRead(characteristic: BleCharacteristicDescriptor): BleResult { 27 | if (!scope.isActive) return BleFailure.Rejected 28 | return queue enqueue ReadCharacteristicBleOperation(controller.deviceId, characteristic) { 29 | controller.readValue(characteristic) 30 | } 31 | } 32 | 33 | override suspend fun setValue( 34 | characteristic: BleCharacteristicDescriptor, value: ByteArray 35 | ): BleResult { 36 | if (!scope.isActive) return BleFailure.Rejected 37 | return queue enqueue WriteCharacteristicBleOperation(controller.deviceId, characteristic) { 38 | controller.writeValue(characteristic, value) 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchHeartRateSensorAutoConnector.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.ble.BleConnectable 4 | import io.sellmair.pacemaker.bluetooth.HeartRateSensor 5 | import io.sellmair.pacemaker.bluetooth.HeartRateSensorBluetoothService 6 | import io.sellmair.pacemaker.bluetooth.toHeartRateSensorId 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Deferred 9 | import kotlinx.coroutines.Job 10 | import kotlinx.coroutines.launch 11 | 12 | internal fun CoroutineScope.launchHeartRateSensorAutoConnector( 13 | userService: UserService, 14 | heartRateSensorBluetoothService: Deferred 15 | ) = launch { 16 | heartRateSensorBluetoothService.await().newSensorsNearby.collect { sensor -> 17 | /* 18 | A sensor that is already connected at startup: 19 | Maybe the sensor is already used for another running app. We therefore can safely assume 20 | that such sensor can be linked ot our account! 21 | */ 22 | if (sensor.connectionState.value == BleConnectable.ConnectionState.Connected) { 23 | userService.linkSensor(userService.me(), sensor.deviceId.toHeartRateSensorId()) 24 | } 25 | launchHeartRateSensorAutoConnector(userService, sensor) 26 | } 27 | } 28 | 29 | private fun CoroutineScope.launchHeartRateSensorAutoConnector(userService: UserService, sensor: HeartRateSensor): Job = launch { 30 | val meId = userService.me().id 31 | 32 | userService.findUserFlow(sensor.deviceId.toHeartRateSensorId()).collect { userOrNull -> 33 | if (userOrNull != null && (userOrNull.id == meId || userOrNull.isAdhoc)) { 34 | sensor.connectIfPossible(true) 35 | } else { 36 | sensor.connectIfPossible(false) 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/BluetoothState.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.evas.State 4 | import io.sellmair.evas.launchState 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.Job 7 | import kotlinx.coroutines.delay 8 | import kotlinx.coroutines.flow.combine 9 | import kotlinx.coroutines.flow.flow 10 | import kotlin.time.Duration.Companion.seconds 11 | 12 | data class BluetoothState( 13 | val isBluetoothEnabled: Boolean, 14 | val isBluetoothPermissionGranted: Boolean 15 | ) : State { 16 | companion object Key : State.Key { 17 | override val default: BluetoothState? = null 18 | } 19 | } 20 | 21 | internal fun CoroutineScope.launchBluetoothStateActor(): Job = launchState(BluetoothState) { 22 | val isBluetoothEnabledFlow = flow { 23 | while (true) { 24 | val isBluetoothEnabled = isBluetoothEnabled() 25 | emit(isBluetoothEnabled) 26 | delay(1.seconds) 27 | } 28 | } 29 | 30 | val isBluetoothPermissionGrantedFlow = flow { 31 | while (true) { 32 | val isBluetoothPermissionGranted = isBluetoothPermissionGranted() 33 | emit(isBluetoothPermissionGranted) 34 | /* Cannot be revoked w/o restarting the process. Once granted we can assume that this is it */ 35 | if (isBluetoothPermissionGranted) break 36 | delay(0.5.seconds) 37 | } 38 | } 39 | 40 | this emitAll combine(isBluetoothEnabledFlow, isBluetoothPermissionGrantedFlow) { isBluetoothEnabled, isBluetoothPermissionGranted -> 41 | BluetoothState(isBluetoothEnabled, isBluetoothPermissionGranted) 42 | } 43 | } 44 | 45 | internal expect suspend fun isBluetoothEnabled(): Boolean 46 | 47 | internal expect suspend fun isBluetoothPermissionGranted(): Boolean -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/ExperimentalFeatureToggle.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.widget 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.combinedClickable 5 | import androidx.compose.foundation.interaction.MutableInteractionSource 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.runtime.rememberCoroutineScope 9 | import androidx.compose.ui.Modifier 10 | import io.sellmair.evas.compose.eventsOrNull 11 | import io.sellmair.pacemaker.ApplicationFeature 12 | import kotlinx.coroutines.delay 13 | import kotlinx.coroutines.launch 14 | import kotlin.coroutines.EmptyCoroutineContext 15 | import kotlin.time.Duration.Companion.seconds 16 | 17 | 18 | @OptIn(ExperimentalFoundationApi::class) 19 | @Composable 20 | fun Modifier.experimentalFeatureToggle(): Modifier { 21 | val eventBus = eventsOrNull() 22 | val coroutineScope = rememberCoroutineScope { eventBus ?: EmptyCoroutineContext } 23 | val experimentalFeatures = ApplicationFeature.entries.filter { !it.default } 24 | 25 | var doubleClicked = false 26 | 27 | return combinedClickable( 28 | onLongClick = { 29 | if (!doubleClicked) return@combinedClickable 30 | coroutineScope.launch { 31 | experimentalFeatures.forEach { feature -> 32 | feature.toggle() 33 | } 34 | } 35 | }, 36 | onDoubleClick = { 37 | doubleClicked = true 38 | coroutineScope.launch { 39 | delay(2.seconds) 40 | doubleClicked = false 41 | } 42 | }, 43 | onClick = {}, 44 | indication = null, 45 | interactionSource = remember { MutableInteractionSource() } 46 | ) 47 | } -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/MemberHeartRateLimit.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.widget 2 | 3 | import androidx.compose.animation.core.Animatable 4 | import androidx.compose.foundation.Canvas 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.remember 9 | import androidx.compose.runtime.rememberCoroutineScope 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.graphics.Brush 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.unit.dp 14 | import io.sellmair.pacemaker.UserState 15 | import io.sellmair.pacemaker.model.HeartRate 16 | import io.sellmair.pacemaker.displayColorLight 17 | import io.sellmair.pacemaker.ui.toColor 18 | import kotlinx.coroutines.launch 19 | 20 | @Composable 21 | internal fun MemberHeartRateLimit( 22 | userState: UserState, 23 | range: ClosedRange 24 | ) { 25 | val user = userState.user 26 | val heartRateLimit = userState.heartRateLimit ?: return 27 | 28 | val animatableHeartRateLimit = remember(user.id.value) { Animatable(heartRateLimit.value) } 29 | rememberCoroutineScope().launch { 30 | animatableHeartRateLimit.animateTo( 31 | heartRateLimit.value, 32 | animationSpec = onHeartRateScaleSpring() 33 | ) 34 | } 35 | 36 | OnHeartRateScalePosition(HeartRate(animatableHeartRateLimit.value), range, side = ScaleSide.Left) { 37 | Canvas( 38 | modifier = Modifier 39 | .fillMaxWidth(.4f) 40 | .height(1.dp) 41 | ) { 42 | drawRect( 43 | Brush.linearGradient( 44 | listOf(Color.Transparent, userState.displayColorLight.toColor()), 45 | ) 46 | ) 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /app-core/src/iosMain/kotlin/io/sellmair/pacemaker/IosApplicationBackend.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import app.cash.sqldelight.driver.native.NativeSqliteDriver 4 | import com.russhwolf.settings.NSUserDefaultsSettings 5 | import com.russhwolf.settings.Settings 6 | import io.sellmair.evas.* 7 | import io.sellmair.pacemaker.ble.AppleBle 8 | import io.sellmair.pacemaker.bluetooth.HeartRateSensorBluetoothService 9 | import io.sellmair.pacemaker.bluetooth.PacemakerBluetoothService 10 | import io.sellmair.pacemaker.sql.PacemakerDatabase 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.SupervisorJob 14 | import kotlinx.coroutines.async 15 | import platform.Foundation.NSUserDefaults 16 | import kotlin.coroutines.CoroutineContext 17 | 18 | class IosApplicationBackend : ApplicationBackend, CoroutineScope { 19 | 20 | override val coroutineContext: CoroutineContext = Dispatchers.Main + SupervisorJob() + Events() + States() 21 | 22 | override val events: Events get() = coroutineContext.eventsOrThrow 23 | 24 | override val states: States = coroutineContext.statesOrThrow 25 | 26 | private val ble = AppleBle() 27 | 28 | override val pacemakerBluetoothService = async { PacemakerBluetoothService(ble) } 29 | 30 | override val heartRateSensorBluetoothService = async { HeartRateSensorBluetoothService(ble) } 31 | 32 | override val settings: Settings = NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults) 33 | 34 | private val meId by lazy { settings.meId } 35 | 36 | private val pacemakerDatabase = SafePacemakerDatabase { 37 | PacemakerDatabase(NativeSqliteDriver(PacemakerDatabase.Schema, "app.db")) 38 | } 39 | 40 | override val userService: UserService by lazy { 41 | SqlUserService(pacemakerDatabase, meId) 42 | } 43 | 44 | override val sessionService: SessionService by lazy { 45 | SqlSessionService(pacemakerDatabase) 46 | } 47 | 48 | 49 | init { 50 | launchApplicationBackend(this) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/Ble.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.flow.SharedFlow 5 | import kotlinx.coroutines.flow.StateFlow 6 | 7 | interface Ble { 8 | val coroutineScope: CoroutineScope 9 | fun close() 10 | suspend fun createCentralService(service: BleServiceDescriptor): BleCentralService 11 | suspend fun createPeripheralService(service: BleServiceDescriptor): BlePeripheralService 12 | } 13 | 14 | interface BleWritable { 15 | suspend fun setValue(characteristic: BleCharacteristicDescriptor, value: ByteArray): BleUnit 16 | } 17 | 18 | class BleReceivedValue( 19 | val deviceId: BleDeviceId, 20 | val characteristic: BleCharacteristicDescriptor, 21 | val data: ByteArray 22 | ) 23 | 24 | interface BleConnectable { 25 | enum class ConnectionState { 26 | Disconnected, 27 | Connecting, 28 | Connected 29 | } 30 | 31 | val deviceName: String? 32 | val deviceId: BleDeviceId 33 | val service: BleServiceDescriptor 34 | 35 | val connection: SharedFlow 36 | val connectionState: StateFlow 37 | val connectIfPossible: StateFlow 38 | fun connectIfPossible(connect: Boolean) 39 | 40 | val rssi: StateFlow 41 | } 42 | 43 | interface BleConnection : BleWritable { 44 | val deviceId: BleDeviceId 45 | val scope: CoroutineScope 46 | val service: BleServiceDescriptor 47 | val receivedValues: SharedFlow 48 | 49 | 50 | suspend fun enableNotifications(characteristic: BleCharacteristicDescriptor): BleUnit 51 | suspend fun requestRead(characteristic: BleCharacteristicDescriptor): BleResult 52 | } 53 | 54 | interface BlePeripheralService : BleWritable { 55 | val service: BleServiceDescriptor 56 | val receivedWrites: SharedFlow 57 | 58 | suspend fun startAdvertising() 59 | } 60 | 61 | interface BleCentralService { 62 | /** 63 | * Will replay all connectables on subscribe! 64 | */ 65 | val connectables: SharedFlow 66 | fun startScanning() 67 | } -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/ApplicationBackend.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import com.russhwolf.settings.Settings 4 | import io.sellmair.evas.Events 5 | import io.sellmair.evas.States 6 | import io.sellmair.pacemaker.bluetooth.HeartRateSensorBluetoothService 7 | import io.sellmair.pacemaker.bluetooth.PacemakerBluetoothService 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.Deferred 10 | 11 | interface ApplicationBackend { 12 | val pacemakerBluetoothService: Deferred 13 | val heartRateSensorBluetoothService: Deferred 14 | val sessionService: SessionService 15 | val userService: UserService 16 | val states: States 17 | val events: Events 18 | val settings: Settings 19 | } 20 | 21 | fun ApplicationBackend.launchApplicationBackend(scope: CoroutineScope) { 22 | scope.launchGroupStateActor(userService) 23 | scope.launchMeStateActor(userService) 24 | scope.launchPacemakerBroadcastSender(pacemakerBluetoothService) 25 | scope.launchPacemakerBroadcastReceiver(userService, pacemakerBluetoothService) 26 | scope.launchHeartRateSensorMeasurement(heartRateSensorBluetoothService) 27 | scope.launchHeartRateSensorAutoConnector(userService, heartRateSensorBluetoothService) 28 | scope.launchCriticalGroupStateActor() 29 | scope.launchSessionActor(userService, sessionService) 30 | scope.launchHeartRateUtteranceProducer() 31 | scope.launchUtteranceSettingsActor(settings) 32 | scope.launchApplicationFeatureActor(settings) 33 | scope.launchAdhocUserActor(userService) 34 | scope.launchHeartRateSensorLinkingActor(userService) 35 | scope.launchUpdateMeActor(userService) 36 | scope.launchHeartRateSensorsStateActor(heartRateSensorBluetoothService) 37 | scope.launchHeartRateSensorStateActor(userService, heartRateSensorBluetoothService) 38 | scope.launchHeartRateSensorConnectionStateActor(userService, heartRateSensorBluetoothService) 39 | scope.launchSessionStateActor(sessionService) 40 | scope.launchMeColorStateActor(settings) 41 | scope.launchBluetoothStateActor() 42 | launchPlatform(scope) 43 | } 44 | 45 | expect fun ApplicationBackend.launchPlatform(scope: CoroutineScope) 46 | -------------------------------------------------------------------------------- /app-core/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 15 | 18 | 19 | 20 | 22 | 23 | 25 | 28 | 29 | 31 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /bluetooth-core/src/commonMain/kotlin/io/sellmair/pacemaker/ble/impl/BleCentralServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble.impl 2 | 3 | import io.sellmair.pacemaker.ble.BleCentralController 4 | import io.sellmair.pacemaker.ble.BleCentralService 5 | import io.sellmair.pacemaker.ble.BleConnectable 6 | import io.sellmair.pacemaker.ble.BleDeviceId 7 | import io.sellmair.pacemaker.ble.BleQueue 8 | import io.sellmair.pacemaker.ble.BleServiceDescriptor 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.channels.Channel 11 | import kotlinx.coroutines.flow.MutableSharedFlow 12 | import kotlinx.coroutines.flow.consumeAsFlow 13 | import kotlinx.coroutines.launch 14 | 15 | internal class BleCentralServiceImpl( 16 | scope: CoroutineScope, 17 | private val queue: BleQueue, 18 | private val controller: BleCentralController, 19 | private val service: BleServiceDescriptor 20 | ) : BleCentralService { 21 | 22 | private val connectableById = mutableMapOf() 23 | 24 | override val connectables: MutableSharedFlow = MutableSharedFlow(replay = Channel.UNLIMITED) 25 | 26 | override fun startScanning() { 27 | controller.startScanning() 28 | } 29 | 30 | init { 31 | scope.launch { 32 | controller.scanResults.consumeAsFlow().collect { result -> 33 | connectableById.getOrPut(result.deviceId) { 34 | BleConnectableImpl( 35 | scope, queue, controller.createConnectableController(result), service 36 | ).also { newConnectable -> 37 | connectables.emit(newConnectable) 38 | } 39 | }.onScanResult(result) 40 | } 41 | } 42 | 43 | scope.launch { 44 | controller.connectedDevices.consumeAsFlow().collect { connectedDevice -> 45 | connectableById.getOrPut(connectedDevice.deviceId) { 46 | BleConnectableImpl( 47 | scope, queue, connectedDevice, service 48 | ).also { newConnectable -> 49 | connectables.emit(newConnectable) 50 | } 51 | } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/UserHead.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.widget 2 | 3 | import androidx.compose.foundation.Canvas 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.foundation.layout.wrapContentHeight 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.graphics.SolidColor 14 | import androidx.compose.ui.text.TextStyle 15 | import androidx.compose.ui.text.style.TextAlign 16 | import androidx.compose.ui.unit.Dp 17 | import androidx.compose.ui.unit.dp 18 | import androidx.compose.ui.unit.sp 19 | import io.sellmair.pacemaker.UserState 20 | import io.sellmair.pacemaker.displayColor 21 | import io.sellmair.pacemaker.model.nameAbbreviation 22 | import io.sellmair.pacemaker.ui.toColor 23 | 24 | @Composable 25 | internal fun UserHead( 26 | userState: UserState, 27 | modifier: Modifier = Modifier, 28 | size: Dp = 24.dp, 29 | ) { 30 | UserHead( 31 | abbreviation = userState.user.nameAbbreviation, 32 | color = userState.displayColor.toColor(), 33 | modifier = modifier, 34 | size = size 35 | ) 36 | } 37 | 38 | @Composable 39 | internal fun UserHead( 40 | abbreviation: String, 41 | color: Color, 42 | modifier: Modifier = Modifier, 43 | size: Dp = 24.dp, 44 | ) { 45 | Box(modifier.size(size), contentAlignment = Alignment.Center) { 46 | Canvas(modifier = Modifier.fillMaxSize()) { 47 | drawCircle(SolidColor(color)) 48 | } 49 | Text( 50 | text = abbreviation, 51 | color = Color.White, 52 | fontSize = 10.sp, 53 | textAlign = TextAlign.Center, 54 | style = TextStyle.Default, 55 | modifier = Modifier 56 | .fillMaxSize() 57 | .wrapContentHeight( 58 | align = Alignment.CenterVertically, // Default value 59 | unbounded = true // Makes sense if the size less than text 60 | ) 61 | ) 62 | } 63 | } -------------------------------------------------------------------------------- /bluetooth/src/commonMain/kotlin/io/sellmair/pacemaker/bluetooth/PacemakerServiceDescriptors.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.bluetooth 2 | 3 | import io.sellmair.pacemaker.ble.BleCharacteristicDescriptor 4 | import io.sellmair.pacemaker.ble.BleServiceDescriptor 5 | import io.sellmair.pacemaker.ble.BleUUID 6 | 7 | internal object PacemakerServiceDescriptors { 8 | val userIdCharacteristic = BleCharacteristicDescriptor( 9 | name = "userId", 10 | uuid = BleUUID(PacemakerServiceConstants.userIdCharacteristicUuidString), 11 | isReadable = true, 12 | isWritable = true, 13 | isNotificationsEnabled = false 14 | ) 15 | 16 | val userNameCharacteristic = BleCharacteristicDescriptor( 17 | name = "userName", 18 | uuid = BleUUID(PacemakerServiceConstants.userNameCharacteristicUuidString), 19 | isReadable = true, 20 | isWritable = true, 21 | isNotificationsEnabled = false 22 | ) 23 | 24 | val userColorHueCharacteristic = BleCharacteristicDescriptor( 25 | name = "userColorHue", 26 | uuid = BleUUID(PacemakerServiceConstants.userColorHueCharacteristcUuidString), 27 | isReadable = true, 28 | isWritable = true, 29 | isNotificationsEnabled = true 30 | ) 31 | 32 | val heartRateCharacteristic = BleCharacteristicDescriptor( 33 | name = "heartRate", 34 | uuid = BleUUID(PacemakerServiceConstants.heartRateCharacteristicUuidString), 35 | isReadable = true, 36 | isWritable = true, 37 | isNotificationsEnabled = true 38 | ) 39 | 40 | val heartRateLimitCharacteristic = BleCharacteristicDescriptor( 41 | name = "heartRateLimit", 42 | uuid = BleUUID(PacemakerServiceConstants.heartRateLimitCharacteristicUuidString), 43 | isReadable = true, 44 | isWritable = true, 45 | isNotificationsEnabled = true 46 | ) 47 | 48 | val service = BleServiceDescriptor( 49 | name = "Pacemaker App", 50 | uuid = BleUUID(PacemakerServiceConstants.serviceUuidString), 51 | characteristics = setOf( 52 | userIdCharacteristic, 53 | userNameCharacteristic, 54 | userColorHueCharacteristic, 55 | heartRateCharacteristic, 56 | heartRateLimitCharacteristic 57 | ) 58 | ) 59 | } 60 | 61 | -------------------------------------------------------------------------------- /app-core/src/androidMain/kotlin/io/sellmair/pacemaker/launchTextToSpeech.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import android.content.Context 4 | import android.media.AudioAttributes 5 | import android.media.AudioFocusRequest 6 | import android.media.AudioManager 7 | import android.speech.tts.TextToSpeech 8 | import androidx.core.content.getSystemService 9 | import io.sellmair.evas.collectEvents 10 | import kotlinx.coroutines.CoroutineScope 11 | import kotlinx.coroutines.delay 12 | import kotlinx.coroutines.launch 13 | import kotlin.coroutines.resume 14 | import kotlin.coroutines.suspendCoroutine 15 | 16 | internal fun CoroutineScope.launchTextToSpeech(context: Context) = launch { 17 | val textToSpeech = TextToSpeech(context) ?: return@launch 18 | textToSpeech.setSpeechRate(1.3f) 19 | textToSpeech.setPitch(0.85f) 20 | 21 | collectEvents { event -> 22 | if (!UtteranceState.shouldBeAnnounced(event)) return@collectEvents 23 | if (textToSpeech.isSpeaking) return@collectEvents 24 | 25 | val audioAttributes = AudioAttributes.Builder() 26 | .setUsage(AudioAttributes.USAGE_ASSISTANT) 27 | .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) 28 | .build() 29 | textToSpeech.setAudioAttributes(audioAttributes) 30 | 31 | val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) 32 | .setAudioAttributes(audioAttributes) 33 | .setAcceptsDelayedFocusGain(false) 34 | .setWillPauseWhenDucked(false) 35 | .build() 36 | 37 | val audioManager = context.getSystemService() 38 | 39 | audioManager?.requestAudioFocus(focusRequest) 40 | textToSpeech.speak(event.message, TextToSpeech.QUEUE_FLUSH, null) 41 | 42 | do { 43 | delay(100) 44 | } while (textToSpeech.isSpeaking) 45 | 46 | audioManager?.abandonAudioFocusRequest(focusRequest) 47 | } 48 | } 49 | 50 | 51 | private suspend fun TextToSpeech(context: Context): TextToSpeech? { 52 | return suspendCoroutine { continuation -> 53 | lateinit var textToSpeech: TextToSpeech 54 | textToSpeech = TextToSpeech(context) { status -> 55 | if (status == TextToSpeech.SUCCESS) { 56 | continuation.resume(textToSpeech) 57 | } else continuation.resume(null) 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:SuppressLint("TestManifestGradleConfiguration") 2 | @file:OptIn(ExperimentalKotlinGradlePluginApi::class) 3 | 4 | import android.annotation.SuppressLint 5 | import com.android.build.api.dsl.ApplicationExtension 6 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 7 | import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget 8 | 9 | plugins { 10 | `pacemaker-application` 11 | org.jetbrains.compose 12 | org.jetbrains.kotlin.plugin.compose 13 | id("org.jetbrains.compose.hot-reload") 14 | } 15 | 16 | pacemaker { 17 | ios() 18 | android() 19 | jvm() 20 | } 21 | 22 | extensions.configure(ApplicationExtension::class) { 23 | namespace = "io.sellmair.pacemaker" 24 | 25 | defaultConfig { 26 | versionName = "2025.1" 27 | versionCode = 17 28 | } 29 | } 30 | 31 | kotlin { 32 | compilerOptions { 33 | optIn.add("org.jetbrains.compose.resources.ExperimentalResourceApi") 34 | } 35 | 36 | sourceSets.commonMain.get().dependencies { 37 | implementation(project(":app-core")) 38 | 39 | implementation(deps.evas.compose) 40 | 41 | /* COMPOSE */ 42 | implementation(compose.ui) 43 | implementation(compose.foundation) 44 | implementation(compose.runtime) 45 | implementation(compose.components.resources) 46 | implementation(compose.components.uiToolingPreview) 47 | 48 | implementation(compose.material3) 49 | implementation(compose.materialIconsExtended) 50 | 51 | } 52 | 53 | sourceSets.jvmMain.dependencies { 54 | implementation(compose.desktop.currentOs) 55 | implementation(deps.coroutines.swing) 56 | } 57 | 58 | sourceSets.androidMain.get().dependencies { 59 | /* androidx */ 60 | implementation("androidx.activity:activity-compose:1.9.3") 61 | implementation(compose.preview) 62 | } 63 | 64 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 65 | sourceSets.invokeWhenCreated("androidDebug") { 66 | dependencies { 67 | implementation(compose.uiTooling) 68 | } 69 | } 70 | 71 | sourceSets.getByName("androidInstrumentedTest").dependencies { 72 | implementation("androidx.compose.ui:ui-test-junit4:1.7.6") 73 | } 74 | } 75 | 76 | kotlin.targets.withType().configureEach { 77 | binaries.framework { 78 | baseName = "LibPacemaker" 79 | isStatic = true 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchAdhocUserActor.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.model.HeartRate 4 | import io.sellmair.pacemaker.model.HeartRateSensorId 5 | import io.sellmair.pacemaker.model.User 6 | import io.sellmair.pacemaker.model.randomUserId 7 | import io.sellmair.evas.Event 8 | import io.sellmair.evas.collectEvents 9 | import io.sellmair.evas.collectEventsAsync 10 | import io.sellmair.evas.Events 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.Job 14 | import kotlinx.coroutines.launch 15 | import kotlin.math.absoluteValue 16 | 17 | sealed class AdhocUserIntent : Event { 18 | data class CreateAdhocUser(val sensor: HeartRateSensorId) : AdhocUserIntent() 19 | data class UpdateAdhocUser(val user: User) : AdhocUserIntent() 20 | data class DeleteAdhocUser(val user: User) : AdhocUserIntent() 21 | data class UpdateAdhocUserLimit(val user: User, val limit: HeartRate) : AdhocUserIntent() 22 | } 23 | 24 | internal fun CoroutineScope.launchAdhocUserActor(userService: UserService): Job = launch(Dispatchers.Main.immediate) { 25 | suspend fun createAdhocUser(intent: AdhocUserIntent.CreateAdhocUser) { 26 | val id = randomUserId() 27 | val adhocUser = User( 28 | id = id, 29 | name = "Adhoc ${id.value.absoluteValue % 1000}", 30 | isAdhoc = true 31 | ) 32 | userService.saveUser(adhocUser) 33 | userService.linkSensor(adhocUser, intent.sensor) 34 | userService.saveHeartRateLimit(adhocUser, HeartRate(130)) 35 | } 36 | 37 | suspend fun updateAdhocUser(intent: AdhocUserIntent.UpdateAdhocUser) { 38 | userService.saveUser(intent.user) 39 | } 40 | 41 | suspend fun deleteAdhocUser(intent: AdhocUserIntent.DeleteAdhocUser) { 42 | userService.deleteUser(intent.user) 43 | } 44 | 45 | suspend fun updateAdhocUserLimit(intent: AdhocUserIntent.UpdateAdhocUserLimit) { 46 | userService.saveHeartRateLimit(intent.user, intent.limit) 47 | } 48 | 49 | collectEvents { intent -> 50 | when (intent) { 51 | is AdhocUserIntent.CreateAdhocUser -> createAdhocUser(intent) 52 | is AdhocUserIntent.DeleteAdhocUser -> deleteAdhocUser(intent) 53 | is AdhocUserIntent.UpdateAdhocUser -> updateAdhocUser(intent) 54 | is AdhocUserIntent.UpdateAdhocUserLimit -> updateAdhocUserLimit(intent) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/ApplicationFeature.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import com.russhwolf.settings.Settings 4 | import com.russhwolf.settings.set 5 | import io.sellmair.evas.* 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlin.time.Duration.Companion.INFINITE 9 | 10 | enum class ApplicationFeature(val default: Boolean) { 11 | /** 12 | * Allow to record and then view sessions 13 | */ 14 | Sessions(false); 15 | 16 | val state get() = ApplicationFeatureState.Key(this) 17 | 18 | suspend fun enable() { 19 | ApplicationFeatureEvent.Enable(this).emit() 20 | } 21 | 22 | suspend fun disable() { 23 | ApplicationFeatureEvent.Disable(this).emit() 24 | } 25 | 26 | suspend fun toggle() { 27 | ApplicationFeatureEvent.Toggle(this).emit() 28 | } 29 | } 30 | 31 | data class ApplicationFeatureState( 32 | val feature: ApplicationFeature, 33 | val enabled: Boolean, 34 | ) : State { 35 | data class Key(val feature: ApplicationFeature) : State.Key { 36 | override val default: ApplicationFeatureState = when (feature) { 37 | ApplicationFeature.Sessions -> ApplicationFeatureState(feature, feature.default) 38 | } 39 | } 40 | } 41 | 42 | sealed class ApplicationFeatureEvent : Event { 43 | data class Enable(val feature: ApplicationFeature) : ApplicationFeatureEvent() 44 | data class Disable(val feature: ApplicationFeature) : ApplicationFeatureEvent() 45 | data class Toggle(val feature: ApplicationFeature) : ApplicationFeatureEvent() 46 | } 47 | 48 | internal fun CoroutineScope.launchApplicationFeatureActor(settings: Settings) = launchState( 49 | coroutineContext = Dispatchers.Main.immediate, 50 | keepActive = INFINITE 51 | ) { key: ApplicationFeatureState.Key -> 52 | val settingsKey = "${ApplicationFeature::class.qualifiedName}:${key.feature.name}" 53 | var enabled = settings.getBooleanOrNull(settingsKey) ?: key.default.enabled 54 | ApplicationFeatureState(key.feature, enabled).emit() 55 | 56 | collectEvents { event -> 57 | enabled = when (event) { 58 | is ApplicationFeatureEvent.Disable -> false 59 | is ApplicationFeatureEvent.Enable -> true 60 | is ApplicationFeatureEvent.Toggle -> !enabled 61 | } 62 | ApplicationFeatureState(key.feature, enabled).emit() 63 | settings[settingsKey] = enabled 64 | } 65 | } 66 | 67 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchHeartRateUtteranceProducer.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.pacemaker.model.HeartRate 4 | import io.sellmair.evas.Event 5 | import io.sellmair.evas.collect 6 | import io.sellmair.evas.emit 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.delay 9 | import kotlinx.coroutines.launch 10 | import kotlin.math.roundToInt 11 | import kotlin.time.Duration.Companion.minutes 12 | import kotlin.time.Duration.Companion.seconds 13 | 14 | 15 | sealed class HeartRateUtteranceRequest : Event { 16 | /** 17 | * At least one person is in 'critical state', exceeding his limit 18 | */ 19 | class SlowDownHeartRateUtterance(val criticalStates: List) : HeartRateUtteranceRequest() 20 | 21 | /** 22 | * Just a status update 23 | */ 24 | class InfoHeartRateUtterance(val myHeartRate: HeartRate, val myHeartRateLimit: HeartRate) : HeartRateUtteranceRequest() 25 | } 26 | 27 | internal fun CoroutineScope.launchHeartRateUtteranceProducer() = launch { 28 | 29 | var group: GroupState? = null 30 | var criticalUserStates = listOf() 31 | 32 | 33 | /* Text To Speech: Tell user who is over the limit */ 34 | launch { 35 | while (true) { 36 | delay(15.seconds) 37 | val criticalStates = criticalUserStates.toList() 38 | if (criticalStates.isNotEmpty()) { 39 | HeartRateUtteranceRequest.SlowDownHeartRateUtterance(criticalStates).emit() 40 | } 41 | } 42 | } 43 | 44 | /* Text To Speech: Tell heart rate every minute */ 45 | launch { 46 | while (true) { 47 | delay(1.minutes) 48 | val me = group?.members.orEmpty().firstOrNull { it.isMe } 49 | val heartRate = me?.heartRate?.value?.roundToInt() ?: continue 50 | val limit = me.heartRateLimit?.value?.roundToInt() ?: continue 51 | 52 | HeartRateUtteranceRequest.InfoHeartRateUtterance( 53 | myHeartRate = HeartRate(heartRate), 54 | myHeartRateLimit = HeartRate(limit) 55 | ).emit() 56 | } 57 | } 58 | 59 | /* Collect critical member states */ 60 | GroupState.collect { state -> 61 | group = state 62 | criticalUserStates = state.members.filter { memberState -> 63 | val currentHeartRate = memberState.heartRate 64 | val currentHeartRateLimit = memberState.heartRateLimit ?: return@filter false 65 | currentHeartRate > currentHeartRateLimit 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/launchHeartRateUtteranceActor.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui 2 | 3 | import io.sellmair.pacemaker.HeartRateUtteranceRequest 4 | import io.sellmair.pacemaker.UtteranceEvent 5 | import io.sellmair.evas.collectEventsAsync 6 | import io.sellmair.evas.emit 7 | import kotlinx.coroutines.CoroutineScope 8 | import org.jetbrains.compose.resources.getString 9 | import pacemaker.app.generated.resources.* 10 | import pacemaker.app.generated.resources.Res 11 | import pacemaker.app.generated.resources.heart_rate_info_utterance 12 | import pacemaker.app.generated.resources.slow_down_heart_rate_utterance_fragment_me 13 | import pacemaker.app.generated.resources.slow_down_heart_rate_utterance_fragment_other 14 | import kotlin.math.roundToInt 15 | 16 | fun CoroutineScope.launchHeartRateUtteranceActor() = collectEventsAsync { request -> 17 | request.toUtterance().emit() 18 | } 19 | 20 | private suspend fun HeartRateUtteranceRequest.toUtterance() = UtteranceEvent( 21 | type = when (this) { 22 | is HeartRateUtteranceRequest.InfoHeartRateUtterance -> UtteranceEvent.Type.Info 23 | is HeartRateUtteranceRequest.SlowDownHeartRateUtterance -> UtteranceEvent.Type.Warning 24 | }, 25 | message = this.createMessage() 26 | ) 27 | 28 | private suspend fun HeartRateUtteranceRequest.createMessage(): String = when (this) { 29 | is HeartRateUtteranceRequest.InfoHeartRateUtterance -> createInfoHeartRateUtteranceMessage() 30 | is HeartRateUtteranceRequest.SlowDownHeartRateUtterance -> createSlowDownHeartRateUtteranceMessage() 31 | } 32 | 33 | private suspend fun HeartRateUtteranceRequest.InfoHeartRateUtterance.createInfoHeartRateUtteranceMessage(): String { 34 | return getString( 35 | Res.string.heart_rate_info_utterance, 36 | myHeartRate.value.roundToInt(), 37 | myHeartRateLimit.value.roundToInt() 38 | ) 39 | } 40 | 41 | private suspend fun HeartRateUtteranceRequest.SlowDownHeartRateUtterance.createSlowDownHeartRateUtteranceMessage(): String { 42 | return getString(Res.string.slow_down) + " " + criticalStates 43 | .sortedBy { it.isMe } 44 | .map { state -> 45 | if (state.isMe) getString( 46 | Res.string.slow_down_heart_rate_utterance_fragment_me, 47 | state.heartRate.value.roundToInt() 48 | ) 49 | else getString( 50 | Res.string.slow_down_heart_rate_utterance_fragment_other, 51 | state.user.name, 52 | state.heartRate.value.roundToInt() 53 | ) 54 | }.joinToString(". ") 55 | } 56 | -------------------------------------------------------------------------------- /app-core/src/jvmMain/kotlin/io/sellmair/pacemaker/ApplicationBackend.jvm.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver 4 | import com.russhwolf.settings.PreferencesSettings 5 | import com.russhwolf.settings.Settings 6 | import io.sellmair.evas.Events 7 | import io.sellmair.evas.States 8 | import io.sellmair.pacemaker.bluetooth.HeartRateSensor 9 | import io.sellmair.pacemaker.bluetooth.HeartRateSensorBluetoothService 10 | import io.sellmair.pacemaker.bluetooth.PacemakerBluetoothService 11 | import io.sellmair.pacemaker.sql.PacemakerDatabase 12 | import kotlinx.coroutines.CompletableDeferred 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.Deferred 15 | import kotlinx.coroutines.flow.MutableSharedFlow 16 | import kotlinx.coroutines.flow.MutableStateFlow 17 | import kotlinx.coroutines.flow.update 18 | import java.util.prefs.Preferences 19 | 20 | actual fun ApplicationBackend.launchPlatform(scope: CoroutineScope) { 21 | scope.launchHeartRateSensorEmulation() 22 | } 23 | 24 | object JvmApplicationBackend : ApplicationBackend { 25 | override val pacemakerBluetoothService: Deferred 26 | get() = CompletableDeferred() 27 | 28 | override val heartRateSensorBluetoothService: Deferred 29 | get() = CompletableDeferred(JvmHeartRateSensorBluetoothService) 30 | 31 | private val database by lazy { createInMemoryDatabase() } 32 | 33 | override val sessionService: SessionService by lazy { 34 | SqlSessionService(database) 35 | } 36 | 37 | private val meId by lazy { 38 | settings.meId 39 | } 40 | 41 | override val userService: UserService by lazy { 42 | SqlUserService(database, meId) 43 | } 44 | 45 | override val states: States = States() 46 | override val events: Events = Events() 47 | override val settings: Settings = PreferencesSettings(Preferences.userRoot()) 48 | } 49 | 50 | 51 | internal fun createInMemoryDatabase(): SafePacemakerDatabase = SafePacemakerDatabase { 52 | val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) 53 | PacemakerDatabase.Schema.create(driver) 54 | PacemakerDatabase(driver) 55 | } 56 | 57 | object JvmHeartRateSensorBluetoothService : HeartRateSensorBluetoothService { 58 | fun addSensor(sensor: HeartRateSensor) { 59 | allSensorsNearby.update { sensors -> sensors + sensor } 60 | newSensorsNearby.tryEmit(sensor) 61 | } 62 | 63 | override val newSensorsNearby = MutableSharedFlow(replay = 1) 64 | 65 | 66 | override val allSensorsNearby = MutableStateFlow>(emptyList()) 67 | } 68 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/settingsPage/SettingsPageHeader.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.settingsPage 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.text.BasicTextField 8 | import androidx.compose.foundation.text.KeyboardActions 9 | import androidx.compose.foundation.text.KeyboardOptions 10 | import androidx.compose.runtime.* 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.platform.LocalFocusManager 14 | import androidx.compose.ui.text.TextStyle 15 | import androidx.compose.ui.text.input.ImeAction 16 | import androidx.compose.ui.unit.dp 17 | import io.sellmair.pacemaker.UpdateMeIntent 18 | import io.sellmair.pacemaker.model.User 19 | import io.sellmair.pacemaker.model.nameAbbreviation 20 | import io.sellmair.pacemaker.ui.MeColor 21 | import io.sellmair.pacemaker.ui.widget.* 22 | import io.sellmair.evas.emit 23 | 24 | @Composable 25 | internal fun SettingsPageHeader(me: User) { 26 | var userName by remember { mutableStateOf(me.name) } 27 | 28 | Column(modifier = Modifier.padding(24.dp)) { 29 | Row(Modifier.fillMaxWidth()) { 30 | val focusManager = LocalFocusManager.current 31 | 32 | BasicTextField( 33 | modifier = Modifier 34 | .weight(1f) 35 | .align(Alignment.CenterVertically), 36 | value = userName, 37 | textStyle = TextStyle.Headline, 38 | singleLine = true, 39 | keyboardActions = KeyboardActions(onDone = { 40 | this.defaultKeyboardAction(ImeAction.Done) 41 | focusManager.clearFocus() 42 | }), 43 | keyboardOptions = KeyboardOptions( 44 | autoCorrect = false, 45 | imeAction = ImeAction.Done 46 | ), 47 | onValueChange = Launching { newName -> 48 | userName = newName 49 | UpdateMeIntent.UpdateMe(me.copy(name = newName)).emit() 50 | }) 51 | 52 | UserHead( 53 | abbreviation = me.nameAbbreviation, 54 | color = MeColor(), 55 | size = 32.dp, 56 | modifier = Modifier 57 | .padding(4.dp) 58 | .experimentalFeatureToggle() 59 | ) 60 | } 61 | 62 | ColorHueSlider( 63 | modifier = Modifier.fillMaxWidth() 64 | ) 65 | 66 | 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/MeState.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.evas.State 4 | import io.sellmair.evas.collectEventsAsync 5 | import io.sellmair.evas.launchState 6 | import io.sellmair.pacemaker.bluetooth.HeartRateMeasurementEvent 7 | import io.sellmair.pacemaker.model.HeartRate 8 | import io.sellmair.pacemaker.model.User 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.channels.Channel 11 | import kotlinx.coroutines.channels.consumeEach 12 | import kotlinx.coroutines.flow.conflate 13 | import kotlinx.coroutines.launch 14 | 15 | data class MeState( 16 | val me: User, 17 | val heartRate: HeartRate?, 18 | val heartRateLimit: HeartRate 19 | ) : State { 20 | companion object Key : State.Key { 21 | override val default: MeState? = null 22 | } 23 | } 24 | 25 | internal fun CoroutineScope.launchMeStateActor(userService: UserService) { 26 | 27 | class Values( 28 | val me: User? = null, 29 | val heartRate: HeartRate? = null, 30 | val heartRateLimit: HeartRate? = null 31 | ) 32 | 33 | val valuesChannel = Channel() 34 | 35 | /* Collect measurements */ 36 | collectEventsAsync { measurement -> 37 | val user = userService.findUser(measurement.sensorId) 38 | val me = userService.me() 39 | if (user?.id == me.id) { 40 | valuesChannel.send(Values(me = me, heartRate = measurement.heartRate)) 41 | } 42 | } 43 | 44 | 45 | /* Collect changes to HR limit */ 46 | launch { 47 | userService.findHeartRateLimitFlow(userService.me()).collect { heartRateLimit -> 48 | valuesChannel.send(Values(heartRateLimit = heartRateLimit)) 49 | } 50 | } 51 | 52 | /* Collect changes in user service (maybe 'me' was updated?) */ 53 | launch { 54 | userService.onSaveUser.conflate().collect { 55 | valuesChannel.send(Values(me = userService.me())) 56 | } 57 | } 58 | 59 | 60 | /* Update MeState */ 61 | launchState(MeState) { 62 | var values = Values(me = userService.me()) 63 | 64 | valuesChannel.consumeEach { update -> 65 | values = Values( 66 | me = update.me ?: values.me, 67 | heartRate = update.heartRate ?: values.heartRate, 68 | heartRateLimit = update.heartRateLimit ?: values.heartRateLimit 69 | ) 70 | 71 | MeState( 72 | me = values.me ?: return@consumeEach, 73 | heartRate = values.heartRate, 74 | heartRateLimit = values.heartRateLimit ?: return@consumeEach, 75 | ).emit() 76 | } 77 | } 78 | } 79 | 80 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/PacemakerTheme.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.lightColorScheme 5 | import androidx.compose.runtime.* 6 | import androidx.compose.ui.graphics.Color 7 | import io.sellmair.evas.compose.composeFlow 8 | import io.sellmair.evas.flow 9 | import io.sellmair.pacemaker.MeColorState 10 | import io.sellmair.pacemaker.UserColors 11 | import kotlinx.coroutines.flow.distinctUntilChanged 12 | import kotlinx.coroutines.flow.filterNotNull 13 | import kotlinx.coroutines.flow.map 14 | 15 | interface PacemakerTheme { 16 | 17 | 18 | val meColor: Color 19 | val meColorLight: Color 20 | val meColorWhite: Color 21 | } 22 | 23 | private val PacemakerThemeCompositionLocal = compositionLocalOf { MissingPacemakerTheme } 24 | 25 | @Composable 26 | fun PacemakerTheme(): PacemakerTheme = PacemakerThemeCompositionLocal.current 27 | 28 | @Composable 29 | fun MeColor() = PacemakerTheme().meColor 30 | 31 | @Composable 32 | fun MeColorLight() = PacemakerTheme().meColorLight 33 | 34 | @Composable 35 | fun MeColorWhite() = PacemakerTheme().meColorWhite 36 | 37 | @Composable 38 | fun PacemakerTheme(content: @Composable () -> Unit) { 39 | val pacemakerTheme by MeColorState.composeFlow().filterNotNull() 40 | .map { state -> 41 | PacemakerThemeImpl( 42 | meColor = state.color.toColor(), 43 | meColorLight = UserColors.fromHueLight(state.color.hue).toColor(), 44 | meColorWhite = state.color.copy(lightness = .95f).toColor() 45 | ) 46 | } 47 | .distinctUntilChanged() 48 | .collectAsState(MissingPacemakerTheme) 49 | 50 | val meColor = pacemakerTheme.meColor 51 | 52 | 53 | CompositionLocalProvider( 54 | PacemakerThemeCompositionLocal provides pacemakerTheme 55 | ) { 56 | MaterialTheme( 57 | colorScheme = lightColorScheme( 58 | primary = meColor, 59 | primaryContainer = meColor, 60 | secondaryContainer = meColor, 61 | onSecondaryContainer = Color.White, 62 | onPrimaryContainer = meColor, 63 | onTertiaryContainer = meColor, 64 | onSurface = meColor, 65 | onSurfaceVariant = meColor, 66 | ) 67 | ) { 68 | content() 69 | } 70 | } 71 | } 72 | 73 | private data class PacemakerThemeImpl( 74 | override val meColor: Color, 75 | override val meColorLight: Color, 76 | override val meColorWhite: Color 77 | ) : PacemakerTheme 78 | 79 | 80 | private object MissingPacemakerTheme : PacemakerTheme { 81 | override val meColor: Color = Color.Gray 82 | override val meColorLight: Color = Color.LightGray 83 | override val meColorWhite: Color = Color.White 84 | } 85 | -------------------------------------------------------------------------------- /bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/AppleCentralController.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ble 2 | 3 | import io.sellmair.pacemaker.ble.AppleCentralManagerDelegate.DidDiscoverPeripheral 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.channels.Channel 6 | import kotlinx.coroutines.launch 7 | import platform.CoreBluetooth.CBAdvertisementDataIsConnectable 8 | import platform.CoreBluetooth.CBPeripheral 9 | 10 | internal class AppleCentralController( 11 | private val scope: CoroutineScope, 12 | private val hardware: AppleCentralHardware 13 | ) : BleCentralController { 14 | 15 | override fun startScanning() { 16 | /* Search for already connected devices and emit them */ 17 | val connectedPeripherals = hardware.manager.retrieveConnectedPeripheralsWithServices(listOf(hardware.serviceDescriptor.uuid)) 18 | scope.launch { 19 | @Suppress("UNCHECKED_CAST") 20 | connectedPeripherals as List 21 | connectedPeripherals.forEach { peripheral -> 22 | val delegate = ApplePeripheralDelegate(scope) 23 | peripheral.delegate = delegate 24 | val controller = AppleConnectableController( 25 | scope, hardware, AppleConnectableHardware(peripheral, delegate, hardware.serviceDescriptor) 26 | ) 27 | controller.connect() 28 | connectedDevices.send(controller) 29 | } 30 | } 31 | 32 | hardware.manager.scanForPeripheralsWithServices( 33 | listOf(hardware.serviceDescriptor.uuid), 34 | mutableMapOf() 35 | ) 36 | } 37 | 38 | override val scanResults = Channel() 39 | 40 | override val connectedDevices = Channel() 41 | 42 | override fun createConnectableController(result: BleCentralController.ScanResult): BleConnectableController { 43 | result as MyScanResult 44 | val delegate = ApplePeripheralDelegate(scope) 45 | result.event.peripheral.delegate = delegate 46 | 47 | return AppleConnectableController( 48 | scope, hardware, AppleConnectableHardware(result.event.peripheral, delegate, hardware.serviceDescriptor) 49 | ) 50 | } 51 | 52 | private class MyScanResult(val event: DidDiscoverPeripheral) : BleCentralController.ScanResult { 53 | override val deviceId: BleDeviceId = event.peripheral.deviceId 54 | override val rssi: Int = event.RSSI.intValue 55 | override val isConnectable: Boolean = 56 | (event.advertisementData[CBAdvertisementDataIsConnectable] as? Boolean) ?: true 57 | } 58 | 59 | init { 60 | scope.launch { 61 | hardware.delegate.didDiscoverPeripheral.collect { event -> 62 | scanResults.send(MyScanResult(event)) 63 | } 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/MemberHeartRateIndicator.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.widget 2 | 3 | import androidx.compose.animation.core.Animatable 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.ThumbUp 9 | import androidx.compose.material.icons.filled.Warning 10 | import androidx.compose.material3.Icon 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.runtime.rememberCoroutineScope 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.draw.alpha 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.unit.dp 19 | import io.sellmair.pacemaker.UserState 20 | import io.sellmair.pacemaker.displayColorLight 21 | import io.sellmair.pacemaker.model.HeartRate 22 | import io.sellmair.pacemaker.ui.toColor 23 | import kotlinx.coroutines.launch 24 | 25 | 26 | @Composable 27 | internal fun MemberHeartRateIndicator(member: UserState, range: ClosedRange) { 28 | val side = if (member.isMe) ScaleSide.Right else ScaleSide.Left 29 | val memberCurrentHeartRate = member.heartRate 30 | 31 | val animatableHeartRate = remember { Animatable(memberCurrentHeartRate.value) } 32 | rememberCoroutineScope().launch { 33 | animatableHeartRate.animateTo( 34 | member.heartRate.value, 35 | animationSpec = onHeartRateScaleSpring() 36 | ) 37 | } 38 | 39 | /* Can be null for myself */ 40 | OnHeartRateScalePosition( 41 | HeartRate(animatableHeartRate.value), range, side = side, modifier = Modifier.padding( 42 | start = if (side == ScaleSide.Right) 96.dp else 0.dp, 43 | end = if (side == ScaleSide.Left) 48.dp else 0.dp 44 | ) 45 | ) { 46 | 47 | Row(verticalAlignment = Alignment.CenterVertically) { 48 | UserHead( 49 | userState = member, 50 | modifier = Modifier 51 | .padding(horizontal = 4.dp) 52 | .alpha(0.75f) 53 | ) 54 | 55 | member.heartRateLimit?.let { memberHeartRateLimit -> 56 | if (memberCurrentHeartRate > memberHeartRateLimit) { 57 | Icon( 58 | Icons.Default.Warning, "Too high", 59 | modifier = Modifier.size(12.dp), 60 | tint = Color.Red 61 | ) 62 | } else { 63 | Icon( 64 | Icons.Default.ThumbUp, "OK", 65 | modifier = Modifier.size(12.dp), 66 | tint = member.displayColorLight.toColor() 67 | ) 68 | } 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /dependencies.toml: -------------------------------------------------------------------------------- 1 | # Version catalog converted from buildSrc/src/main/kotlin/Dependencies.kt 2 | 3 | [versions] 4 | kotlin = "2.2.20" 5 | agp = "8.11.1" 6 | compose = "1.9.0-rc02" 7 | composeHotReload = "1.0.0-beta06" 8 | coroutines = "1.10.1" 9 | okio = "3.10.2" 10 | kotlinxDatetime = "0.7.1" 11 | kotlinxImmutable = "0.4.0" 12 | multiplatformSettings = "1.3.0" 13 | evas = "1.3.0" 14 | sqldelight = "2.1.0" 15 | atomicFu = "0.29.0" 16 | androidxCoreKtx = "1.13.1" 17 | androidxActivityCompose = "1.9.3" 18 | 19 | 20 | [libraries] 21 | # kotlinx-coroutines 22 | coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } 23 | coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } 24 | coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } 25 | coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } 26 | 27 | okio = { module = "com.squareup.okio:okio", version.ref = "okio" } 28 | kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } 29 | kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxImmutable" } 30 | multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" } 31 | 32 | sqldelight-gradlePlugin = { module = "app.cash.sqldelight:gradle-plugin", version.ref = "sqldelight" } 33 | sqldelight-androidDriver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight"} 34 | sqldelight-sqliteDriver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight"} 35 | sqldelight-nativeDriver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight"} 36 | sqldelight-coroutineExtensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight"} 37 | 38 | androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" } 39 | androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" } 40 | 41 | # Evas 42 | evas = { module = "io.sellmair:evas", version.ref = "evas" } 43 | evas-compose = { module = "io.sellmair:evas-compose", version.ref = "evas" } 44 | 45 | kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } 46 | kotlin-composeCompilerPlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } 47 | android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } 48 | compose-gradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose" } 49 | compose-hotReload-gradlePlugin = { module = "org.jetbrains.compose.hot-reload:hot-reload-gradle-plugin", version.ref = "composeHotReload" } 50 | atomicFu-gradlePlugin = { module = "org.jetbrains.kotlinx:atomicfu-gradle-plugin", version.ref = "atomicFu" } 51 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/sessionActor.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.evas.Event 4 | import io.sellmair.evas.State 5 | import io.sellmair.evas.collectEvents 6 | import io.sellmair.evas.launchState 7 | import io.sellmair.pacemaker.ActiveSessionIntent.Start 8 | import io.sellmair.pacemaker.ActiveSessionIntent.Stop 9 | import io.sellmair.pacemaker.bluetooth.HeartRateMeasurementEvent 10 | import io.sellmair.pacemaker.bluetooth.PacemakerBroadcastPackageEvent 11 | import io.sellmair.pacemaker.model.Session 12 | import kotlinx.coroutines.CoroutineScope 13 | import kotlinx.coroutines.Job 14 | import kotlinx.coroutines.coroutineScope 15 | import kotlinx.coroutines.launch 16 | 17 | data class ActiveSessionState(val session: Session?) : State { 18 | companion object : State.Key { 19 | override val default: ActiveSessionState = ActiveSessionState(null) 20 | } 21 | } 22 | 23 | sealed interface ActiveSessionIntent : Event { 24 | data object Start : ActiveSessionIntent 25 | data object Stop : ActiveSessionIntent 26 | } 27 | 28 | internal fun CoroutineScope.launchSessionActor( 29 | userService: UserService, sessionService: SessionService 30 | ) = launchState(ActiveSessionState) { 31 | var activeSession: ActiveSessionService? = null 32 | var activeSessionActor: Job? = null 33 | 34 | collectEvents { event -> 35 | when (event) { 36 | Start -> { 37 | activeSession?.stop() 38 | activeSessionActor?.cancel() 39 | val activeSessionService = sessionService.createSession() 40 | activeSession = activeSessionService 41 | ActiveSessionState(activeSession?.session).emit() 42 | activeSessionActor = launchActiveSessionActor(userService, activeSessionService) 43 | } 44 | 45 | Stop -> { 46 | activeSession?.stop() 47 | activeSessionActor?.cancel() 48 | ActiveSessionState(null).emit() 49 | } 50 | } 51 | } 52 | } 53 | 54 | 55 | internal fun CoroutineScope.launchActiveSessionActor( 56 | userService: UserService, 57 | activeSessionService: ActiveSessionService 58 | ) = launch { 59 | coroutineScope { 60 | launch { 61 | collectEvents { event -> 62 | val user = userService.findUser(event.pkg.userId) ?: return@collectEvents 63 | activeSessionService.save(user, event.pkg.heartRate, event.pkg.heartRateLimit, event.pkg.receivedTime) 64 | } 65 | } 66 | 67 | launch { 68 | collectEvents { event -> 69 | val user = userService.findUser(event.sensorId) ?: return@collectEvents 70 | val heartRateLimit = userService.findHeartRateLimit(user) 71 | activeSessionService.save(user, event.heartRate, heartRateLimit, event.time) 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /spoof-tool/src/macosMain/kotlin/io.sellmair.broadheart.spoof/Main.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.spoof 2 | 3 | import io.sellmair.pacemaker.ble.AppleBle 4 | import io.sellmair.pacemaker.bluetooth.PacemakerBluetoothService 5 | import io.sellmair.pacemaker.model.HeartRate 6 | import io.sellmair.pacemaker.model.User 7 | import io.sellmair.pacemaker.model.UserId 8 | import io.sellmair.pacemaker.utils.LogTag 9 | import io.sellmair.pacemaker.utils.info 10 | import kotlinx.coroutines.* 11 | import platform.CoreFoundation.CFRunLoopRun 12 | 13 | 14 | private val ble = AppleBle() 15 | 16 | 17 | fun main() { 18 | launchSendBroadcasts() 19 | //launchReceiveBroadcasts() 20 | /* launchReceiveHeartRates() */ 21 | CFRunLoopRun() 22 | } 23 | 24 | private fun launchSendBroadcasts() = MainScope().launch { 25 | MainScope().launch(Dispatchers.Default) { 26 | val user = User( 27 | id = UserId(2412), 28 | name = "Felix Werner" 29 | ) 30 | 31 | val pacemakerPeripheral = PacemakerBluetoothService(ble) 32 | pacemakerPeripheral.write { 33 | setUser(user) 34 | setHeartRateLimit(HeartRate(120)) 35 | setHeartRate(HeartRate(130)) 36 | } 37 | 38 | 39 | while (isActive) { 40 | yield() 41 | val line = readln() 42 | if (line.startsWith("l")) { 43 | val heartRateLimit = line.removePrefix("l").toIntOrNull() ?: continue 44 | pacemakerPeripheral.write { 45 | setHeartRateLimit(HeartRate(heartRateLimit)) 46 | println("Updated spoof hr-limit: $heartRateLimit") 47 | } 48 | 49 | } else { 50 | pacemakerPeripheral.write { 51 | setHeartRate(HeartRate(line.toIntOrNull() ?: return@write)) 52 | println("Updated spoof hr: ${line.toIntOrNull()}") 53 | } 54 | } 55 | } 56 | } 57 | } 58 | 59 | private fun launchReceiveBroadcasts() = MainScope().launch(Dispatchers.Default) { 60 | val pacemaker = PacemakerBluetoothService(ble) 61 | pacemaker.newConnections.collect { connection -> 62 | LogTag("spoof").info("Received connection ${connection.deviceId}") 63 | } 64 | } 65 | 66 | /* 67 | private fun launchReceiveBroadcasts() = MainScope().launch(Dispatchers.Default) { 68 | val pacemakerBle = PacemakerBle(ble) 69 | pacemakerBle.connections.flatMapMerge { connection -> 70 | println("Found pacemaker peripheral: ${connection.id}") 71 | connection.receivePacemakerBroadcastPackages() 72 | }.collect { pkg -> 73 | println("${pkg.userName}: ${pkg.heartRate.value.roundToInt()}/${pkg.heartRateLimit.value.roundToInt()}") 74 | } 75 | } 76 | 77 | private fun launchReceiveHeartRates() = MainScope().launch(Dispatchers.Default) {/* 78 | Ble(this).receiveHeartRateMeasurements().collect { measurement -> 79 | println("HR: ${measurement.heartRate} | Device: ${measurement.sensorInfo.id}") 80 | }*/ 81 | } 82 | 83 | 84 | */ -------------------------------------------------------------------------------- /bluetooth-core/src/appleMain/kotlin/io/sellmair/pacemaker/ble/ApplePeripheralManagerDelegate.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("unused") 2 | 3 | package io.sellmair.pacemaker.ble 4 | 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.flow.MutableSharedFlow 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.launch 9 | import platform.CoreBluetooth.CBATTRequest 10 | import platform.CoreBluetooth.CBPeripheralManager 11 | import platform.CoreBluetooth.CBPeripheralManagerDelegateProtocol 12 | import platform.CoreBluetooth.CBService 13 | import platform.Foundation.NSError 14 | import platform.darwin.NSObject 15 | 16 | internal class ApplePeripheralManagerDelegate( 17 | private val scope: CoroutineScope 18 | ) : NSObject(), CBPeripheralManagerDelegateProtocol { 19 | 20 | private val thisDelegate = this 21 | 22 | /* State */ 23 | 24 | val state = MutableStateFlow(null) 25 | 26 | override fun peripheralManagerDidUpdateState(peripheral: CBPeripheralManager) { 27 | state.value = peripheral.state 28 | } 29 | 30 | 31 | /* Is ready to update subscribers */ 32 | 33 | val isReadyToUpdateSubscribers = MutableSharedFlow() 34 | 35 | override fun peripheralManagerIsReadyToUpdateSubscribers(peripheral: CBPeripheralManager) { 36 | scope.launch { 37 | thisDelegate.isReadyToUpdateSubscribers.emit(Unit) 38 | } 39 | } 40 | 41 | /* Did receive read request */ 42 | 43 | class DidReceiveReadRequest(val peripheral: CBPeripheralManager, val request: CBATTRequest) 44 | 45 | val didReceiveReadRequest = MutableSharedFlow() 46 | 47 | override fun peripheralManager(peripheral: CBPeripheralManager, didReceiveReadRequest: CBATTRequest) { 48 | scope.launch { 49 | thisDelegate.didReceiveReadRequest.emit(DidReceiveReadRequest(peripheral, didReceiveReadRequest)) 50 | } 51 | } 52 | 53 | /* Did receive write request */ 54 | 55 | class DidReceiveWriteRequest( 56 | val peripheral: CBPeripheralManager, val request: CBATTRequest 57 | ) 58 | 59 | val didReceiveWriteRequest = MutableSharedFlow() 60 | 61 | override fun peripheralManager(peripheral: CBPeripheralManager, didReceiveWriteRequests: List<*>) { 62 | didReceiveWriteRequests.forEach { writeRequest -> 63 | scope.launch { 64 | writeRequest as CBATTRequest 65 | thisDelegate.didReceiveWriteRequest.emit(DidReceiveWriteRequest(peripheral, writeRequest)) 66 | } 67 | } 68 | } 69 | 70 | /* Did add service */ 71 | 72 | class DidAddService(val peripheral: CBPeripheralManager, val service: CBService, val error: NSError?) 73 | 74 | val didAddService = MutableSharedFlow() 75 | 76 | override fun peripheralManager(peripheral: CBPeripheralManager, didAddService: CBService, error: NSError?) { 77 | scope.launch { 78 | thisDelegate.didAddService.emit(DidAddService(peripheral, didAddService, error)) 79 | } 80 | } 81 | 82 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if %ERRORLEVEL% equ 0 goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if %ERRORLEVEL% equ 0 goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | set EXIT_CODE=%ERRORLEVEL% 84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 86 | exit /b %EXIT_CODE% 87 | 88 | :mainEnd 89 | if "%OS%"=="Windows_NT" endlocal 90 | 91 | :omega 92 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/mainPage/UtteranceControlButton.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.mainPage 2 | 3 | import androidx.compose.animation.* 4 | import androidx.compose.foundation.ExperimentalFoundationApi 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.automirrored.filled.VolumeOff 8 | import androidx.compose.material.icons.automirrored.filled.VolumeUp 9 | import androidx.compose.material.icons.filled.Warning 10 | import androidx.compose.material3.Button 11 | import androidx.compose.material3.ButtonDefaults 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.LaunchedEffect 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.unit.dp 20 | import io.sellmair.evas.compose.EvasLaunching 21 | import io.sellmair.evas.compose.composeState 22 | import io.sellmair.evas.set 23 | import io.sellmair.pacemaker.UtteranceState 24 | import io.sellmair.pacemaker.ui.MeColor 25 | import io.sellmair.pacemaker.ui.MeColorLight 26 | 27 | @Composable 28 | fun UtteranceControlButton(modifier: Modifier = Modifier) { 29 | val utteranceState by UtteranceState.composeState() 30 | UtteranceControlButton( 31 | state = utteranceState, 32 | modifier = modifier, 33 | onClick = EvasLaunching { 34 | UtteranceState.set(utteranceState.next()) 35 | }, 36 | ) 37 | } 38 | 39 | @OptIn(ExperimentalFoundationApi::class) 40 | @Composable 41 | fun UtteranceControlButton( 42 | state: UtteranceState, 43 | modifier: Modifier = Modifier, 44 | onClick: () -> Unit = {}, 45 | ) { 46 | val desiredColor = when (state) { 47 | UtteranceState.Silence -> Color.Gray 48 | UtteranceState.Warnings -> MeColorLight() 49 | UtteranceState.All -> MeColor() 50 | } 51 | 52 | val color = remember { Animatable(desiredColor) } 53 | 54 | LaunchedEffect(desiredColor) { 55 | color.animateTo(desiredColor) 56 | } 57 | 58 | Button( 59 | colors = ButtonDefaults.buttonColors( 60 | containerColor = color.value 61 | ), 62 | onClick = onClick, 63 | modifier = modifier 64 | .padding(18.dp) 65 | ) { 66 | 67 | AnimatedContent( 68 | targetState = state, 69 | transitionSpec = { 70 | (slideInHorizontally { it } + fadeIn() + scaleIn()) 71 | .togetherWith(slideOutHorizontally { -it } + fadeOut() + scaleOut()) 72 | }, 73 | ) { targetState -> 74 | Icon( 75 | imageVector = when (targetState) { 76 | UtteranceState.Silence -> Icons.AutoMirrored.Default.VolumeOff 77 | UtteranceState.Warnings -> Icons.Default.Warning 78 | UtteranceState.All -> Icons.AutoMirrored.Default.VolumeUp 79 | }, 80 | contentDescription = null, 81 | ) 82 | } 83 | } 84 | } -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/timelinePage/TimelinePage.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.timelinePage 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.defaultMinSize 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.lazy.LazyColumn 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.text.font.FontWeight 15 | import androidx.compose.ui.text.style.TextAlign 16 | import androidx.compose.ui.unit.dp 17 | import io.sellmair.evas.compose.composeValue 18 | import io.sellmair.pacemaker.SessionsState 19 | import io.sellmair.pacemaker.model.Session 20 | import io.sellmair.pacemaker.ui.widget.Headline 21 | import kotlin.time.Clock 22 | import kotlinx.datetime.LocalDateTime 23 | import kotlinx.datetime.TimeZone 24 | import kotlinx.datetime.toLocalDateTime 25 | 26 | @Composable 27 | fun TimelinePage() { 28 | val sessions = SessionsState.composeValue().sessions 29 | TimelinePage(sessions) 30 | } 31 | 32 | @Composable 33 | fun TimelinePage(sessions: List) { 34 | LazyColumn(Modifier.fillMaxWidth()) { 35 | item { 36 | Headline("History", Modifier.padding(24.dp)) 37 | } 38 | 39 | if (sessions.isEmpty()) { 40 | item { 41 | EmptyTimelinePlaceholder() 42 | } 43 | } else { 44 | sessions.forEach { session -> 45 | item(session.id.value) { 46 | SessionItem(session) 47 | } 48 | } 49 | } 50 | } 51 | } 52 | 53 | 54 | @Composable 55 | private fun EmptyTimelinePlaceholder() { 56 | Text( 57 | "No run, yet", 58 | textAlign = TextAlign.Center, 59 | modifier = Modifier 60 | .fillMaxSize() 61 | .defaultMinSize(minHeight = 150.dp) 62 | ) 63 | } 64 | 65 | @Composable 66 | private fun SessionItem(session: Session) { 67 | val startTime = session.startTime.toLocalDateTime(TimeZone.currentSystemDefault()) 68 | Column(Modifier.padding(24.dp)) { 69 | Text( 70 | startTime.dateString(), 71 | fontWeight = FontWeight.Bold 72 | ) 73 | Text( 74 | startTime.clockString(), 75 | fontWeight = FontWeight.Light 76 | ) 77 | Spacer(Modifier.height(12.dp)) 78 | } 79 | } 80 | 81 | private fun LocalDateTime.dateString(): String = buildString { 82 | append(dayOfMonth.twoDigitString()) 83 | append(".") 84 | append(monthNumber.twoDigitString()) 85 | if (year != Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).year) { 86 | append(".${year}") 87 | } 88 | } 89 | 90 | private fun LocalDateTime.clockString(): String = "${hour.twoDigitString()}:${minute.twoDigitString()}" 91 | 92 | private fun Int.twoDigitString(): String { 93 | return if (this >= 10) this.toString() 94 | else "0$this" 95 | } 96 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/SqlSessionService.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import app.cash.sqldelight.coroutines.asFlow 4 | import io.sellmair.pacemaker.model.HeartRate 5 | import io.sellmair.pacemaker.model.Session 6 | import io.sellmair.pacemaker.model.SessionId 7 | import io.sellmair.pacemaker.model.User 8 | import io.sellmair.pacemaker.model.UserId 9 | import io.sellmair.pacemaker.utils.value 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.map 12 | import kotlin.time.Instant 13 | 14 | internal class SqlSessionService(private val database: SafePacemakerDatabase) : SessionService { 15 | override suspend fun createSession(): ActiveSessionService = database { 16 | val startTime = SessionService.SessionClock.value().now() 17 | transactionWithResult { 18 | sessionQueries.newSession(startTime.toString()) 19 | val id = sessionQueries.lastSessionId().executeAsOne() 20 | SqlActiveSessionService(Session(SessionId(id), startTime, null), database) 21 | } 22 | } 23 | 24 | override suspend fun getSessions(): List { 25 | return database { 26 | sessionQueries.allSessions().executeAsList().map { dbSession -> 27 | SqlStoredSessionService( 28 | session = dbSession.toSession(), 29 | database = database 30 | ) 31 | } 32 | } 33 | } 34 | 35 | override val sessionsFlow: Flow> = database.flow { 36 | sessionQueries.allSessions() 37 | .asFlow().map { query -> query.executeAsList() } 38 | .map { values -> values.map { it.toStroedSessionService() } } 39 | } 40 | 41 | private fun Db_session.toStroedSessionService() = SqlStoredSessionService( 42 | session = toSession(), 43 | database = database 44 | ) 45 | 46 | } 47 | 48 | private class SqlStoredSessionService( 49 | override val session: Session, 50 | private val database: SafePacemakerDatabase 51 | ) : StoredSessionService { 52 | 53 | override suspend fun getUsers(): List { 54 | return database { 55 | sessionQueries.findUsers(session_id = session.id.value).executeAsList() 56 | .mapNotNull { userId -> userQueries.findUserById(userId).executeAsOneOrNull() } 57 | .map { it.toUser() } 58 | } 59 | } 60 | 61 | override suspend fun getHeartRateMeasurements(user: User): List = database { 62 | sessionQueries.findHeartRateMeasurements(session.id.value).executeAsList().map { dbRecord -> 63 | SessionRecord( 64 | sessionId = session.id, 65 | userId = UserId(dbRecord.user_id), 66 | time = Instant.parse(dbRecord.time), 67 | heartRate = HeartRate(dbRecord.heart_rate.toFloat()), 68 | heartRateLimit = dbRecord.heart_rate_limit?.let { HeartRate(it.toFloat()) } 69 | ) 70 | } 71 | } 72 | } 73 | 74 | private fun Db_session.toSession() = Session( 75 | id = SessionId(id), 76 | startTime = Instant.parse(start_time), 77 | endTime = end_time?.let { Instant.parse(end_time) } 78 | ) 79 | -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/mainPage/MyStatusHeader.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.mainPage 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.outlined.FavoriteBorder 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.geometry.Offset 13 | import androidx.compose.ui.graphics.Brush 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.text.font.FontWeight 16 | import androidx.compose.ui.unit.dp 17 | import androidx.compose.ui.unit.sp 18 | import io.sellmair.evas.compose.composeValue 19 | import io.sellmair.pacemaker.MeState 20 | import io.sellmair.pacemaker.ui.MeColor 21 | import io.sellmair.pacemaker.ui.MeColorLight 22 | import io.sellmair.pacemaker.ui.widget.experimentalFeatureToggle 23 | 24 | @Composable 25 | fun MyStatusHeader() { 26 | MyStatusHeader(MeState.composeValue()) 27 | } 28 | 29 | 30 | @Composable 31 | fun MyStatusHeader(state: MeState?) { 32 | Box { 33 | Column( 34 | Modifier 35 | .fillMaxWidth() 36 | .height(248.dp) 37 | .background( 38 | Brush.linearGradient( 39 | listOf(Color.White, Color.White, Color.Transparent), 40 | start = Offset.Zero, 41 | end = Offset(0f, Float.POSITIVE_INFINITY) 42 | ) 43 | ), 44 | horizontalAlignment = Alignment.CenterHorizontally 45 | ) { 46 | 47 | /* Ensure big HR number and Settings icon are aligned vertically */ 48 | Box( 49 | Modifier 50 | .fillMaxWidth() 51 | .height(IntrinsicSize.Min) 52 | ) { 53 | SessionStartStopButton( 54 | Modifier.align(Alignment.CenterStart) 55 | ) 56 | 57 | Text( 58 | state?.heartRate?.toString() ?: "🤷‍♂️", 59 | fontWeight = FontWeight.Black, 60 | fontSize = 48.sp, 61 | modifier = Modifier 62 | .padding(top = 8.dp) 63 | .align(Alignment.Center) 64 | .experimentalFeatureToggle() 65 | ) 66 | 67 | UtteranceControlButton( 68 | Modifier.align(Alignment.CenterEnd) 69 | ) 70 | } 71 | 72 | if (state?.heartRateLimit != null) 73 | Text( 74 | state.heartRateLimit.toString(), 75 | Modifier.offset(y = (-4).dp), 76 | fontWeight = FontWeight.Light, 77 | fontSize = 10.sp, 78 | color = MeColor() 79 | ) 80 | 81 | Icon( 82 | Icons.Outlined.FavoriteBorder, "Heart", 83 | Modifier.offset(y = (-4).dp), 84 | tint = MeColorLight() 85 | ) 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /app/src/commonMain/kotlin/io/sellmair/pacemaker/ui/widget/HeartRateScale.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker.ui.widget 2 | 3 | import androidx.compose.foundation.Canvas 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.draw.clipToBounds 9 | import androidx.compose.ui.geometry.Offset 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.graphics.SolidColor 12 | import androidx.compose.ui.text.font.FontWeight 13 | import androidx.compose.ui.unit.dp 14 | import androidx.compose.ui.unit.sp 15 | import io.sellmair.pacemaker.model.HeartRate 16 | import io.sellmair.pacemaker.model.step 17 | import kotlin.math.roundToInt 18 | 19 | 20 | @Composable 21 | internal fun HeartRateScale( 22 | modifier: Modifier = Modifier, 23 | range: ClosedRange = HeartRate(40)..HeartRate(200f), 24 | horizontalCenterBias: Float = .5f, 25 | content: @Composable () -> Unit = {}, 26 | ) { 27 | Box(modifier.clipToBounds()) { 28 | Canvas( 29 | modifier = Modifier.fillMaxSize() 30 | ) { 31 | val center = drawContext.size.run { 32 | Offset( 33 | x = horizontalCenterBias * width, 34 | y = height / 2f 35 | ) 36 | } 37 | val brush = SolidColor(Color.Gray) 38 | drawLine(brush, Offset(center.x, 0f), Offset(center.x, size.height), strokeWidth = 3f) 39 | 40 | (range step 10).forEach { heartRate -> 41 | val y = yOfHeartRate(HeartRate(heartRate), range, size.height) 42 | drawLine(brush, Offset(center.x - 20, y), Offset(center.x + 20, y), strokeWidth = 2f) 43 | } 44 | } 45 | 46 | (range step 5).map(::HeartRate).forEach { heartRate -> 47 | OnHeartRateScalePosition( 48 | heartRate = heartRate, 49 | range = range, 50 | horizontalCenterBias = horizontalCenterBias, 51 | modifier = Modifier.padding(start = 24.dp) 52 | ) { 53 | val isEmphasized = heartRate.value.roundToInt() % 10 == 0 54 | Text( 55 | heartRate.toString(), 56 | fontWeight = if (isEmphasized) FontWeight.Bold else FontWeight.Thin, 57 | fontSize = if (isEmphasized) 12.sp else 10.sp, 58 | ) 59 | } 60 | } 61 | 62 | content() 63 | } 64 | } 65 | 66 | fun yOfHeartRate(heartRate: HeartRate, range: ClosedRange, height: Float): Float { 67 | if (heartRate > range.endInclusive) return 0f 68 | if (heartRate < range.start) return height 69 | val rangeWidth = range.endInclusive.value - range.start.value 70 | val relativeInRange = (heartRate.value - range.start.value) / rangeWidth 71 | return height * (1f - relativeInRange) 72 | } 73 | 74 | fun heartRateOfY(y: Float, range: ClosedRange, height: Float): HeartRate { 75 | if (y > height) return range.start 76 | if (y < 0) return range.endInclusive 77 | val rangeWidth = range.endInclusive.value - range.start.value 78 | val hr = range.endInclusive.value - (rangeWidth * y) / height 79 | return HeartRate(hr) 80 | } 81 | -------------------------------------------------------------------------------- /app-core/src/androidUnitTest/kotlin/GroupStateTest.kt: -------------------------------------------------------------------------------- 1 | import io.sellmair.evas.* 2 | import io.sellmair.pacemaker.* 3 | import io.sellmair.pacemaker.bluetooth.HeartRateMeasurementEvent 4 | import io.sellmair.pacemaker.model.HeartRate 5 | import io.sellmair.pacemaker.model.HeartRateSensorId 6 | import io.sellmair.pacemaker.model.UserId 7 | import io.sellmair.pacemaker.utils.value 8 | import kotlinx.coroutines.ExperimentalCoroutinesApi 9 | import kotlinx.coroutines.cancelChildren 10 | import kotlinx.coroutines.currentCoroutineContext 11 | import kotlinx.coroutines.flow.first 12 | import kotlinx.coroutines.launch 13 | import kotlinx.coroutines.test.TestScope 14 | import kotlinx.coroutines.test.runTest 15 | import utils.createInMemoryDatabase 16 | import kotlin.test.Test 17 | import kotlin.test.assertEquals 18 | import kotlin.time.Clock 19 | 20 | @OptIn(ExperimentalCoroutinesApi::class) 21 | class GroupStateTest { 22 | 23 | private val userService = SqlUserService(createInMemoryDatabase(), UserId(1)) 24 | 25 | @Test 26 | fun `sample 0`() = test { 27 | val meSensorId = HeartRateSensorId("me") 28 | val me = userService.me() 29 | userService.linkSensor(me, meSensorId) 30 | HeartRateMeasurementEvent(HeartRate(128f), meSensorId, Clock.System.now()).emit() 31 | 32 | launch { 33 | GroupState.collect { println("(${testScheduler.currentTime}ms) s: $it") } 34 | } 35 | 36 | val state = GroupState.flow().first { it.members.isNotEmpty() } 37 | println("(${testScheduler.currentTime}ms) Received first state with non-empty members") 38 | 39 | assertEquals( 40 | GroupState( 41 | listOf( 42 | UserState( 43 | user = me, 44 | isMe = true, 45 | heartRate = HeartRate(128f), 46 | heartRateLimit = UserService.NewUserHeartRateLimit.value(), 47 | color = UserColors.default(me.id) 48 | ) 49 | ) 50 | ), state, 51 | "Expect first state with non-empty members to reflect the previously emitted '${HeartRateMeasurementEvent::class}'" 52 | ) 53 | 54 | println("(${testScheduler.currentTime}ms) Testing current state value...") 55 | assertEquals( 56 | state, GroupState.value(), 57 | "(${testScheduler.currentTime}ms) Expect the current value of 'GroupState' to be the recently emitted state" 58 | ) 59 | 60 | println("(${testScheduler.currentTime}ms) Waiting until the Group state resets..") 61 | GroupState.flow().first { it == GroupState.default } 62 | 63 | println("(${testScheduler.currentTime}ms) Group state was reset!") 64 | assertEquals( 65 | GroupState.KeepMeasurementDuration.value().inWholeMilliseconds, 66 | testScheduler.currentTime, 67 | "Expect that the default value was emitted after the correct time" 68 | ) 69 | } 70 | 71 | private fun test(block: suspend TestScope.() -> Unit) = runTest(Events() + States()) { 72 | try { 73 | launchGroupStateActor(userService, actorContext = currentCoroutineContext()) 74 | block() 75 | } finally { 76 | currentCoroutineContext().cancelChildren() 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app-core/src/commonMain/kotlin/io/sellmair/pacemaker/launchPacemakerBroadcastSender.kt: -------------------------------------------------------------------------------- 1 | package io.sellmair.pacemaker 2 | 3 | import io.sellmair.evas.flow 4 | import io.sellmair.pacemaker.bluetooth.PacemakerBluetoothService 5 | import io.sellmair.pacemaker.model.Hue 6 | import io.sellmair.evas.value 7 | import kotlinx.coroutines.* 8 | import kotlinx.coroutines.flow.conflate 9 | import kotlinx.coroutines.flow.distinctUntilChanged 10 | import kotlinx.coroutines.flow.mapNotNull 11 | import kotlinx.coroutines.flow.sample 12 | import kotlin.time.Duration.Companion.milliseconds 13 | import kotlin.time.Duration.Companion.seconds 14 | 15 | @OptIn(FlowPreview::class) 16 | internal fun CoroutineScope.launchPacemakerBroadcastSender( 17 | pacemakerBluetoothService: Deferred 18 | ) { 19 | /* Start broadcasting my own state to other participant */ 20 | 21 | /* Broadcast changes to 'me' */ 22 | launch { 23 | MeState.flow().mapNotNull { state -> state?.me } 24 | .distinctUntilChanged() 25 | .conflate() 26 | .collect { me -> 27 | pacemakerBluetoothService.await().write { 28 | setUser(me) 29 | } 30 | } 31 | } 32 | 33 | /* Broadcast changes to 'heartRateLimit' */ 34 | launch { 35 | MeState.flow().mapNotNull { state -> state?.heartRateLimit } 36 | .sample(32.milliseconds) 37 | .distinctUntilChanged() 38 | .conflate() 39 | .collect { heartRateLimit -> 40 | pacemakerBluetoothService.await().write { 41 | setHeartRateLimit(heartRateLimit) 42 | } 43 | } 44 | } 45 | 46 | /* Broadcast changes to 'heartRate' */ 47 | launch { 48 | MeState.flow().mapNotNull { state -> state?.heartRate } 49 | .sample(32.milliseconds) 50 | .distinctUntilChanged() 51 | .conflate() 52 | .collect { heartRate -> 53 | pacemakerBluetoothService.await().write { 54 | setHeartRate(heartRate) 55 | } 56 | } 57 | } 58 | 59 | /* Broadcast my current color */ 60 | launch { 61 | MeColorState.flow().mapNotNull { state -> state?.color } 62 | .sample(32.milliseconds) 63 | .distinctUntilChanged() 64 | .conflate() 65 | .collect { meColor -> 66 | pacemakerBluetoothService.await().write { 67 | setColorHue(Hue.safe(meColor.hue)) 68 | } 69 | } 70 | } 71 | 72 | /* Fallback: Broadcast regardless of changes (in case a previous broadcast was lost) */ 73 | launch { 74 | while (isActive) { 75 | delay(15.seconds) 76 | 77 | MeState.value()?.let { state -> 78 | pacemakerBluetoothService.await().write { 79 | setUser(state.me) 80 | setHeartRateLimit(state.heartRateLimit) 81 | state.heartRate?.let { setHeartRate(it) } 82 | } 83 | } 84 | 85 | MeColorState.value()?.let { state -> 86 | pacemakerBluetoothService.await().write { 87 | setColorHue(Hue.safe(state.color.hue)) 88 | } 89 | } 90 | } 91 | } 92 | } 93 | --------------------------------------------------------------------------------