#![expect(unsafe_code)]
use std::{
cell::{Cell, RefCell},
sync::{Arc, OnceLock},
time::Duration,
};
use block2::RcBlock;
use cranpose_services::{
HeartRate, HeartRateError, HeartRateMonitor, HeartRatePermission, HeartRateStatus,
LifecycleObserver, LifecycleState, current_lifecycle_state, observe_lifecycle,
publish_heart_rate, publish_heart_rate_permission, set_platform_heart_rate,
};
use dispatch2::{DispatchQueue, run_on_main};
use objc2::{AllocAnyThread, rc::Retained, runtime::Bool};
use objc2_foundation::{NSBundle, NSDate, NSError, NSSet, ns_string};
use objc2_health_kit::{
HKAuthorizationStatus, HKHealthStore, HKLiveWorkoutBuilder, HKLiveWorkoutDataSource,
HKObjectType, HKQuantityType, HKQuantityTypeIdentifierHeartRate, HKUnit, HKWorkoutActivityType,
HKWorkoutConfiguration, HKWorkoutSession, HKWorkoutSessionLocationType,
};
use crate::apple_mobile::on_main_after;
const POLL: Duration = Duration::from_secs(1);
pub(crate) fn register() {
static FOLLOWS_THE_APP: OnceLock<LifecycleObserver> = OnceLock::new();
set_platform_heart_rate(Arc::new(WatchHeartRate));
FOLLOWS_THE_APP
.get_or_init(|| observe_lifecycle(|event| run_on_main(move |_| follow_the_app(event.to))));
}
struct WatchHeartRate;
struct Workout {
session: Retained<HKWorkoutSession>,
builder: Retained<HKLiveWorkoutBuilder>,
turn: u64,
}
thread_local! {
static STORE: Retained<HKHealthStore> = unsafe { HKHealthStore::new() };
static WORKOUT: RefCell<Option<Workout>> = const { RefCell::new(None) };
static TURN: Cell<u64> = const { Cell::new(0) };
static READING: Cell<bool> = const { Cell::new(false) };
}
impl HeartRateMonitor for WatchHeartRate {
fn available(&self) -> bool {
let health = unsafe { HKHealthStore::isHealthDataAvailable() };
health
&& NSBundle::mainBundle()
.objectForInfoDictionaryKey(ns_string!("NSHealthShareUsageDescription"))
.is_some()
}
fn permission(&self) -> HeartRatePermission {
let status = run_on_main(|_| {
STORE.with(|store| {
unsafe { store.authorizationStatusForType(&HKObjectType::workoutType()) }
})
});
match status {
HKAuthorizationStatus::SharingAuthorized => HeartRatePermission::Granted,
HKAuthorizationStatus::SharingDenied => HeartRatePermission::Denied,
_ => HeartRatePermission::NotAsked,
}
}
fn request_permission(&self) {
run_on_main(|_| {
let workouts = unsafe { HKObjectType::workoutType() };
let share = NSSet::from_retained_slice(&[workouts.into_super()]);
let read = NSSet::from_retained_slice(&[heart_rate_type().into_super().into_super()]);
let answered = RcBlock::new(|_done: Bool, _error: *mut NSError| {
DispatchQueue::main().exec_async(|| publish_heart_rate_permission(true));
});
STORE.with(|store| {
unsafe {
store.requestAuthorizationToShareTypes_readTypes_completion(
Some(&share),
Some(&read),
&answered,
);
}
});
});
}
fn start(&self) -> Result<(), HeartRateError> {
run_on_main(|_| {
READING.set(true);
if matches!(
current_lifecycle_state(),
LifecycleState::Paused | LifecycleState::Stopped
) {
return Ok(());
}
start_workout()
})
.map_err(HeartRateError::Failed)
}
fn stop(&self) {
run_on_main(|_| {
READING.set(false);
end_workout();
});
}
}
fn follow_the_app(state: LifecycleState) {
match state {
LifecycleState::Resumed if READING.get() => {
if let Err(error) = start_workout() {
log::warn!("cranpose: the heart-rate workout did not start again: {error}");
}
}
LifecycleState::Paused | LifecycleState::Stopped => {
if WORKOUT.with_borrow(Option::is_some) {
end_workout();
publish_heart_rate(HeartRate {
status: HeartRateStatus::Acquiring,
bpm: None,
});
}
}
_ => {}
}
}
fn end_workout() {
let Some(workout) = WORKOUT.take() else {
return;
};
unsafe {
workout.session.end();
workout.builder.discardWorkout();
}
}
fn start_workout() -> Result<(), String> {
if WORKOUT.with_borrow(Option::is_some) {
return Ok(());
}
STORE.with(|store| {
let configuration = unsafe {
let configuration = HKWorkoutConfiguration::new();
configuration.setActivityType(HKWorkoutActivityType::Other);
configuration.setLocationType(HKWorkoutSessionLocationType::Indoor);
configuration
};
let session = unsafe {
HKWorkoutSession::initWithHealthStore_configuration_error(
HKWorkoutSession::alloc(),
store,
&configuration,
)
}
.map_err(|error| format!("no workout session: {error}"))?;
let builder = unsafe {
let builder = session.associatedWorkoutBuilder();
let source = HKLiveWorkoutDataSource::initWithHealthStore_workoutConfiguration(
HKLiveWorkoutDataSource::alloc(),
store,
Some(&configuration),
);
builder.setDataSource(Some(&source));
let now = NSDate::now();
session.startActivityWithDate(Some(&now));
let began = RcBlock::new(|done: Bool, _error: *mut NSError| {
if !done.as_bool() {
log::warn!("cranpose: the workout did not start collecting the heart rate");
}
});
builder.beginCollectionWithStartDate_completion(&now, &began);
builder
};
let turn = TURN.get().wrapping_add(1);
TURN.set(turn);
WORKOUT.set(Some(Workout {
session,
builder,
turn,
}));
poll(turn);
Ok(())
})
}
fn poll(turn: u64) {
on_main_after(POLL, move |_| {
let reading = WORKOUT.with_borrow(|workout| {
workout
.as_ref()
.filter(|workout| workout.turn == turn)
.map(|workout| latest(&workout.builder))
});
if let Some(reading) = reading {
publish_heart_rate(reading);
poll(turn);
}
});
}
fn latest(builder: &HKLiveWorkoutBuilder) -> HeartRate {
let bpm = unsafe {
builder
.statisticsForType(&heart_rate_type())
.and_then(|statistics| statistics.mostRecentQuantity())
.map(|quantity| quantity.doubleValueForUnit(&beats_per_minute()))
};
match bpm {
Some(bpm) if bpm.is_finite() && bpm > 0.0 => HeartRate {
status: HeartRateStatus::Live,
bpm: Some(bpm as f32),
},
_ => HeartRate {
status: HeartRateStatus::Acquiring,
bpm: None,
},
}
}
fn heart_rate_type() -> Retained<HKQuantityType> {
unsafe { HKObjectType::quantityTypeForIdentifier(HKQuantityTypeIdentifierHeartRate) }
.expect("HealthKit has a heart-rate type")
}
fn beats_per_minute() -> Retained<HKUnit> {
unsafe { HKUnit::countUnit().unitDividedByUnit(&HKUnit::minuteUnit()) }
}