#![expect(unsafe_code)]
use std::{
cell::{Cell, OnceCell, 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,
}
pub(crate) struct HeartRateThreadState {
store: OnceCell<Retained<HKHealthStore>>,
workout: RefCell<Option<Workout>>,
turn: Cell<u64>,
reading: Cell<bool>,
}
impl HeartRateThreadState {
pub(crate) const fn new() -> Self {
Self {
store: OnceCell::new(),
workout: RefCell::new(None),
turn: Cell::new(0),
reading: Cell::new(false),
}
}
}
fn heart_rate_state<R>(f: impl FnOnce(&HeartRateThreadState) -> R) -> R {
crate::thread_state::with(|state| f(&state.heart_rate))
}
fn with_store<R>(f: impl FnOnce(&HKHealthStore) -> R) -> R {
heart_rate_state(|state| {
f(state.store.get_or_init(|| unsafe { HKHealthStore::new() }))
})
}
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(|_| {
with_store(|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));
});
with_store(|store| {
unsafe {
store.requestAuthorizationToShareTypes_readTypes_completion(
Some(&share),
Some(&read),
&answered,
);
}
});
});
}
fn start(&self) -> Result<(), HeartRateError> {
run_on_main(|_| {
heart_rate_state(|state| state.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(|_| {
heart_rate_state(|state| state.reading.set(false));
end_workout();
});
}
}
fn follow_the_app(state: LifecycleState) {
match state {
LifecycleState::Resumed if heart_rate_state(|heart| heart.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 heart_rate_state(|heart| heart.workout.borrow().is_some()) =>
{
end_workout();
publish_heart_rate(HeartRate {
status: HeartRateStatus::Acquiring,
bpm: None,
});
}
_ => {}
}
}
fn end_workout() {
let Some(workout) = heart_rate_state(|state| state.workout.take()) else {
return;
};
unsafe {
workout.session.end();
workout.builder.discardWorkout();
}
}
fn start_workout() -> Result<(), String> {
if heart_rate_state(|state| state.workout.borrow().is_some()) {
return Ok(());
}
with_store(|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 = heart_rate_state(|state| {
let turn = state.turn.get().wrapping_add(1);
state.turn.set(turn);
*state.workout.borrow_mut() = Some(Workout {
session,
builder,
turn,
});
turn
});
poll(turn);
Ok(())
})
}
fn poll(turn: u64) {
on_main_after(POLL, move |_| {
let reading = heart_rate_state(|state| {
state
.workout
.borrow()
.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()) }
}