diff --git a/.gitignore b/.gitignore index 2559ef1..3512959 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ credentials.json !.env.*.example # Build output +modules/breathly-health-connect/android/build/ *.ipa *.apk *.aab diff --git a/app.json b/app.json index 9bdbb50..392ac03 100644 --- a/app.json +++ b/app.json @@ -58,7 +58,8 @@ "expo-build-properties", { "android": { - "enableProguardInReleaseBuilds": true + "enableProguardInReleaseBuilds": true, + "minSdkVersion": 26 } } ], diff --git a/modules/breathly-health-connect/android/build.gradle b/modules/breathly-health-connect/android/build.gradle new file mode 100644 index 0000000..c6d2e29 --- /dev/null +++ b/modules/breathly-health-connect/android/build.gradle @@ -0,0 +1,19 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'com.mmazzarolo.breathly.healthconnect' +version = '1.0.0' + +android { + namespace 'com.mmazzarolo.breathly.healthconnect' + defaultConfig { + versionCode 1 + versionName '1.0.0' + } +} + +dependencies { + implementation 'androidx.health.connect:connect-client:1.1.0' +} diff --git a/modules/breathly-health-connect/android/src/main/AndroidManifest.xml b/modules/breathly-health-connect/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3bab71e --- /dev/null +++ b/modules/breathly-health-connect/android/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/breathly-health-connect/android/src/main/java/com/mmazzarolo/breathly/healthconnect/BreathlyHealthConnectModule.kt b/modules/breathly-health-connect/android/src/main/java/com/mmazzarolo/breathly/healthconnect/BreathlyHealthConnectModule.kt new file mode 100644 index 0000000..45e2e48 --- /dev/null +++ b/modules/breathly-health-connect/android/src/main/java/com/mmazzarolo/breathly/healthconnect/BreathlyHealthConnectModule.kt @@ -0,0 +1,126 @@ +package com.mmazzarolo.breathly.healthconnect + +import android.os.Build +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.HealthConnectFeatures +import androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.MindfulnessSessionRecord +import androidx.health.connect.client.records.metadata.Device +import androidx.health.connect.client.records.metadata.Metadata +import expo.modules.kotlin.activityresult.AppContextActivityResultLauncher +import expo.modules.kotlin.exception.Exceptions +import expo.modules.kotlin.functions.Coroutine +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import java.time.Instant +import java.time.ZoneId + +@OptIn(ExperimentalMindfulnessSessionApi::class) +class BreathlyHealthConnectModule : Module() { + private val context + get() = appContext.reactContext ?: throw Exceptions.ReactContextLost() + + private val writePermission = + HealthPermission.getWritePermission(MindfulnessSessionRecord::class) + + override fun definition() = ModuleDefinition { + Name("BreathlyHealthConnect") + + lateinit var permissionLauncher: + AppContextActivityResultLauncher> + + RegisterActivityContracts { + permissionLauncher = registerForActivityResult(HealthPermissionRequestContract()) + } + + AsyncFunction("getStatusAsync") Coroutine { -> + getStatus() + } + + AsyncFunction("requestPermissionAsync") Coroutine { -> + if (getStatus() != STATUS_PERMISSION_REQUIRED) return@Coroutine false + + val grantedPermissions = permissionLauncher.launch( + HealthPermissionRequestInput(arrayListOf(writePermission)) + ) + grantedPermissions.contains(writePermission) + } + + AsyncFunction("writeBreathingSessionAsync") Coroutine { startTimeMs: Double, endTimeMs: Double, clientRecordId: String -> + val status = getStatus() + if (status != STATUS_AUTHORIZED) return@Coroutine status + if ( + !startTimeMs.isFinite() || + !endTimeMs.isFinite() || + startTimeMs < 0 || + endTimeMs <= startTimeMs || + clientRecordId.isBlank() + ) { + return@Coroutine STATUS_ERROR + } + + val startTime = Instant.ofEpochMilli(startTimeMs.toLong()) + val endTime = Instant.ofEpochMilli(endTimeMs.toLong()) + val zoneRules = ZoneId.systemDefault().rules + val record = MindfulnessSessionRecord( + startTime = startTime, + startZoneOffset = zoneRules.getOffset(startTime), + endTime = endTime, + endZoneOffset = zoneRules.getOffset(endTime), + mindfulnessSessionType = MindfulnessSessionRecord.MINDFULNESS_SESSION_TYPE_BREATHING, + title = context.getString(R.string.health_connect_session_title), + metadata = Metadata.activelyRecorded( + device = Device( + type = Device.TYPE_PHONE, + manufacturer = Build.MANUFACTURER, + model = Build.MODEL, + ), + clientRecordId = clientRecordId, + clientRecordVersion = 1L, + ), + ) + try { + healthConnectClient().insertRecords(listOf(record)) + STATUS_SAVED + } catch (_: SecurityException) { + STATUS_PERMISSION_REQUIRED + } + } + } + + private suspend fun getStatus(): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return STATUS_UNAVAILABLE + + when (HealthConnectClient.getSdkStatus(context)) { + HealthConnectClient.SDK_UNAVAILABLE -> return STATUS_UNAVAILABLE + HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> return STATUS_UPDATE_REQUIRED + } + + val client = healthConnectClient() + if ( + client.features.getFeatureStatus(HealthConnectFeatures.FEATURE_MINDFULNESS_SESSION) != + HealthConnectFeatures.FEATURE_STATUS_AVAILABLE + ) { + return STATUS_UNSUPPORTED + } + + return if (client.permissionController.getGrantedPermissions().contains(writePermission)) { + STATUS_AUTHORIZED + } else { + STATUS_PERMISSION_REQUIRED + } + } + + private fun healthConnectClient() = HealthConnectClient.getOrCreate(context) + + private companion object { + const val STATUS_UNAVAILABLE = "unavailable" + const val STATUS_UPDATE_REQUIRED = "updateRequired" + const val STATUS_UNSUPPORTED = "unsupported" + const val STATUS_PERMISSION_REQUIRED = "permissionRequired" + const val STATUS_AUTHORIZED = "authorized" + const val STATUS_SAVED = "saved" + const val STATUS_ERROR = "error" + } +} diff --git a/modules/breathly-health-connect/android/src/main/java/com/mmazzarolo/breathly/healthconnect/HealthConnectPermissionRationaleActivity.kt b/modules/breathly-health-connect/android/src/main/java/com/mmazzarolo/breathly/healthconnect/HealthConnectPermissionRationaleActivity.kt new file mode 100644 index 0000000..2fb006c --- /dev/null +++ b/modules/breathly-health-connect/android/src/main/java/com/mmazzarolo/breathly/healthconnect/HealthConnectPermissionRationaleActivity.kt @@ -0,0 +1,70 @@ +package com.mmazzarolo.breathly.healthconnect + +import android.app.Activity +import android.os.Bundle +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView + +class HealthConnectPermissionRationaleActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val density = resources.displayMetrics.density + val padding = (24 * density).toInt() + val content = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(padding, padding, padding, padding) + } + + content.addView( + TextView(this).apply { + text = getString(R.string.health_connect_rationale_title) + textSize = 24f + }, + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + content.addView( + TextView(this).apply { + text = getString(R.string.health_connect_rationale_message) + textSize = 16f + setPadding(0, (16 * density).toInt(), 0, (24 * density).toInt()) + }, + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + content.addView( + TextView(this).apply { + text = getString(R.string.health_connect_privacy_policy_title) + textSize = 20f + }, + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + content.addView( + TextView(this).apply { + text = getString(R.string.health_connect_privacy_policy) + textSize = 16f + setPadding(0, (16 * density).toInt(), 0, (24 * density).toInt()) + }, + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + content.addView( + Button(this).apply { + text = getString(R.string.health_connect_rationale_close) + setOnClickListener { finish() } + }, + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + + setContentView( + ScrollView(this).apply { addView(content) }, + ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + ) + } +} diff --git a/modules/breathly-health-connect/android/src/main/java/com/mmazzarolo/breathly/healthconnect/HealthPermissionRequestContract.kt b/modules/breathly-health-connect/android/src/main/java/com/mmazzarolo/breathly/healthconnect/HealthPermissionRequestContract.kt new file mode 100644 index 0000000..8fffe7b --- /dev/null +++ b/modules/breathly-health-connect/android/src/main/java/com/mmazzarolo/breathly/healthconnect/HealthPermissionRequestContract.kt @@ -0,0 +1,25 @@ +package com.mmazzarolo.breathly.healthconnect + +import android.content.Context +import android.content.Intent +import androidx.health.connect.client.PermissionController +import expo.modules.kotlin.activityresult.AppContextActivityResultContract +import java.io.Serializable + +internal data class HealthPermissionRequestInput( + val permissions: ArrayList +) : Serializable + +internal class HealthPermissionRequestContract : + AppContextActivityResultContract> { + private val contract = PermissionController.createRequestPermissionResultContract() + + override fun createIntent(context: Context, input: HealthPermissionRequestInput): Intent = + contract.createIntent(context, input.permissions.toSet()) + + override fun parseResult( + input: HealthPermissionRequestInput, + resultCode: Int, + intent: Intent? + ): Set = contract.parseResult(resultCode, intent) +} diff --git a/modules/breathly-health-connect/android/src/main/res/values/strings.xml b/modules/breathly-health-connect/android/src/main/res/values/strings.xml new file mode 100644 index 0000000..ed52a86 --- /dev/null +++ b/modules/breathly-health-connect/android/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + Breathing exercise + Breathly and Health Connect + When you enable Health Connect, Breathly writes the start time, end time, and breathing type of completed exercises. Breathly does not read health data. You can revoke access at any time in Health Connect. + Privacy policy + Breathly writes completed breathing-session times and the breathing activity type to Health Connect only when you enable this option. Breathly does not read Health Connect data and does not send this data to a Breathly server. Health Connect controls access to this data. You can revoke Breathly’s access or delete its records at any time in Health Connect. + Close + diff --git a/modules/breathly-health-connect/expo-module.config.json b/modules/breathly-health-connect/expo-module.config.json new file mode 100644 index 0000000..5b33d6d --- /dev/null +++ b/modules/breathly-health-connect/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["com.mmazzarolo.breathly.healthconnect.BreathlyHealthConnectModule"] + } +} diff --git a/src/screens/exercise-screen/__tests__/exercise-screen-health-connect.test.tsx b/src/screens/exercise-screen/__tests__/exercise-screen-health-connect.test.tsx new file mode 100644 index 0000000..5c55f5d --- /dev/null +++ b/src/screens/exercise-screen/__tests__/exercise-screen-health-connect.test.tsx @@ -0,0 +1,181 @@ +import { act, fireEvent, render } from "@testing-library/react-native"; +import React from "react"; +import { Alert, AppState } from "react-native"; +import { saveCompletedBreathingSession } from "@breathly/services/health-connect"; +import { ExerciseScreen } from "../exercise-screen"; + +let onAppStateChange: ((state: "active" | "background") => void) | undefined; +let onStepUpdate: ((previousStep: { id: "exhale" }) => void) | undefined; + +jest.mock("expo-keep-awake", () => ({ useKeepAwake: jest.fn() })); +jest.mock("@expo/vector-icons/Ionicons", () => "Ionicons"); +jest.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); +jest.mock("@breathly/design/theme", () => ({ + useColorScheme: () => "light", + useThemeColors: () => ({ control: "#000", textSecondary: "#000" }), +})); +jest.mock("@breathly/utils/use-accessibility-preferences", () => ({ + useScreenReaderEnabled: () => false, +})); +jest.mock("@breathly/utils/animate", () => ({ + animate: () => ({ + start: (callback: (result: { finished: boolean }) => void) => callback({ finished: true }), + }), +})); +jest.mock("@breathly/utils/use-on-update", () => ({ + useOnUpdate: (callback: (previousStep: { id: "exhale" }) => void) => { + onStepUpdate = callback; + }, +})); +jest.mock("@breathly/utils/build-steps-metadata", () => ({ + buildStepsMetadata: () => [], +})); +jest.mock("@breathly/screens/exercise-screen/accessibility-announcements", () => ({ + announceForScreenReader: jest.fn(), + announceLiveRegionUpdate: jest.fn(), + getStepAccessibilityLabel: jest.fn(), + sessionPausedAnnouncement: "Paused", +})); +jest.mock("@breathly/screens/exercise-screen/use-exercise-audio", () => ({ + useExerciseAudio: () => ({ + playExerciseStepAudio: jest.fn(), + playExerciseCompletedAudio: jest.fn(), + stopExerciseAudio: jest.fn(), + }), +})); +jest.mock("@breathly/screens/exercise-screen/use-exercise-haptics", () => ({ + useExerciseHaptics: () => jest.fn(), +})); +jest.mock("@breathly/screens/exercise-screen/use-exercise-loop", () => ({ + useExerciseLoop: () => ({ + currentStep: { id: "inhale", duration: 1_000, label: "Inhale" }, + exerciseAnimVal: { interpolate: jest.fn() }, + textAnimVal: { interpolate: jest.fn() }, + }), +})); +jest.mock("@breathly/screens/exercise-screen/animated-dots", () => ({ + AnimatedDots: () => null, +})); +jest.mock("@breathly/screens/exercise-screen/breathing-animation", () => ({ + BreathingAnimation: () => null, +})); +jest.mock("@breathly/screens/exercise-screen/step-description", () => ({ + StepDescription: () => null, +})); +jest.mock("@breathly/screens/home-screen/stars-background", () => ({ + StarsBackground: () => null, +})); +jest.mock("../complete", () => { + const { Text } = require("react-native"); + return { ExerciseComplete: () => Completed }; +}); +jest.mock("../interlude", () => ({ + ExerciseInterlude: ({ onComplete }: { onComplete: () => void }) => { + const { Text } = require("react-native"); + return ( + + Start + + ); + }, +})); +jest.mock("../timer", () => ({ + Timer: ({ onLimitReached }: { onLimitReached: () => void }) => { + const { useEffect } = require("react"); + useEffect(onLimitReached, [onLimitReached]); + return null; + }, +})); + +const mockSetHealthConnectEnabled = jest.fn(); +jest.mock("@breathly/stores/settings", () => ({ + useSettingsStore: (selector?: (state: Record) => unknown) => { + const state = { + guidedBreathingVoice: "paul", + healthConnectEnabled: true, + setHealthConnectEnabled: mockSetHealthConnectEnabled, + timeLimit: 60_000, + vibrationEnabled: false, + }; + return selector ? selector(state) : state; + }, + useSelectedPatternSteps: () => [], +})); +jest.mock("@breathly/services/health-connect", () => ({ + saveCompletedBreathingSession: jest.fn(), +})); + +const mockSaveCompletedBreathingSession = jest.mocked(saveCompletedBreathingSession); + +describe("ExerciseScreen Health Connect", () => { + beforeEach(() => { + jest.clearAllMocks(); + onAppStateChange = undefined; + onStepUpdate = undefined; + mockSaveCompletedBreathingSession.mockResolvedValue("saved"); + jest.spyOn(AppState, "addEventListener").mockImplementation((_event, listener) => { + onAppStateChange = listener as (state: "active" | "background") => void; + return { remove: jest.fn() }; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("records active intervals before and after a background pause", async () => { + let now = 1_000; + jest.spyOn(Date, "now").mockImplementation(() => now); + const screen = await render( + , + ); + + await act(async () => { + await fireEvent.press(screen.getByTestId("exercise.interlude")); + }); + now = 5_000; + await act(async () => { + onAppStateChange?.("background"); + }); + now = 8_000; + await act(async () => { + await fireEvent.press(screen.getByTestId("exercise.resume")); + }); + now = 12_000; + await act(async () => { + onStepUpdate?.({ id: "exhale" }); + }); + await act(async () => undefined); + + expect(mockSaveCompletedBreathingSession.mock.calls.map(([segment]) => segment)).toEqual([ + { startedAtMs: 1_000, completedAtMs: 5_000, clientRecordId: "breathly-1000-0" }, + { startedAtMs: 8_000, completedAtMs: 12_000, clientRecordId: "breathly-1000-1" }, + ]); + }); + + it("explains when a clock change makes the completed interval invalid", async () => { + const alertSpy = jest.spyOn(Alert, "alert").mockImplementation(() => undefined); + let now = 1_000; + jest.spyOn(Date, "now").mockImplementation(() => now); + const screen = await render( + , + ); + + await act(async () => { + await fireEvent.press(screen.getByTestId("exercise.interlude")); + }); + now = 500; + await act(async () => { + onStepUpdate?.({ id: "exhale" }); + }); + + expect(mockSaveCompletedBreathingSession).not.toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalledWith( + "Health Connect", + "Breathly could not save this exercise to Health Connect.", + ); + alertSpy.mockRestore(); + }); +}); diff --git a/src/screens/exercise-screen/__tests__/health-connect-session.test.ts b/src/screens/exercise-screen/__tests__/health-connect-session.test.ts new file mode 100644 index 0000000..b536802 --- /dev/null +++ b/src/screens/exercise-screen/__tests__/health-connect-session.test.ts @@ -0,0 +1,27 @@ +import { appendCompletedHealthConnectSegment } from "../health-connect-session"; + +describe("Health Connect exercise segments", () => { + it("keeps active intervals on either side of a background pause", () => { + const beforeBackground = appendCompletedHealthConnectSegment([], 1_000, 5_000, "session"); + const afterResume = appendCompletedHealthConnectSegment( + beforeBackground, + 8_000, + 12_000, + "session", + ); + + expect(afterResume).toEqual([ + { startedAtMs: 1_000, completedAtMs: 5_000, clientRecordId: "session-0" }, + { startedAtMs: 8_000, completedAtMs: 12_000, clientRecordId: "session-1" }, + ]); + }); + + it("does not create an empty segment", () => { + const segments = [{ startedAtMs: 1_000, completedAtMs: 5_000, clientRecordId: "session-0" }]; + + expect(appendCompletedHealthConnectSegment(segments, undefined, 7_000, "session")).toBe( + segments, + ); + expect(appendCompletedHealthConnectSegment(segments, 8_000, 8_000, "session")).toBe(segments); + }); +}); diff --git a/src/screens/exercise-screen/exercise-screen.tsx b/src/screens/exercise-screen/exercise-screen.tsx index 87587de..a4e840c 100644 --- a/src/screens/exercise-screen/exercise-screen.tsx +++ b/src/screens/exercise-screen/exercise-screen.tsx @@ -2,7 +2,7 @@ import Ionicons from "@expo/vector-icons/Ionicons"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { useKeepAwake } from "expo-keep-awake"; import React, { FC, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; -import { Animated, AppState, StyleSheet, Text, View } from "react-native"; +import { Alert, Animated, AppState, StyleSheet, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Pressable } from "@breathly/common/pressable"; import { RootStackParamList } from "@breathly/core/navigator"; @@ -23,11 +23,17 @@ import { getExerciseStepTransition, type ResumableExerciseStatus, } from "@breathly/screens/exercise-screen/exercise-session"; +import { appendCompletedHealthConnectSegment } from "@breathly/screens/exercise-screen/health-connect-session"; import { StepDescription } from "@breathly/screens/exercise-screen/step-description"; import { useExerciseAudio } from "@breathly/screens/exercise-screen/use-exercise-audio"; import { useExerciseHaptics } from "@breathly/screens/exercise-screen/use-exercise-haptics"; import { useExerciseLoop } from "@breathly/screens/exercise-screen/use-exercise-loop"; import { StarsBackground } from "@breathly/screens/home-screen/stars-background"; +import { + saveCompletedBreathingSession, + type CompletedBreathingSession, + type HealthConnectWriteResult, +} from "@breathly/services/health-connect"; import { useSelectedPatternSteps, useSettingsStore } from "@breathly/stores/settings"; import { GuidedBreathingMode } from "@breathly/types/guided-breathing-mode"; import { StepMetadata } from "@breathly/types/step-metadata"; @@ -43,11 +49,29 @@ import { Timer } from "./timer"; // The voice that the exercise uses for a user of a screen reader who disabled // it. It is the default voice of the app. const screenReaderFallbackVoice: GuidedBreathingMode = "paul"; +const healthConnectSaveErrorMessage = "Breathly could not save this exercise to Health Connect."; + +const healthConnectFailureMessages: Partial> = { + unavailable: "Health Connect is not available on this device.", + updateRequired: "Install or update Health Connect to save breathing sessions.", + unsupported: "This version of Health Connect cannot record mindfulness sessions.", + permissionRequired: + "Breathly could not save this exercise because Health Connect access was removed.", + error: healthConnectSaveErrorMessage, +}; + +const healthConnectPermanentFailures = new Set([ + "unavailable", + "updateRequired", + "unsupported", + "permissionRequired", +]); export const ExerciseScreen: FC> = ({ navigation, }) => { - const { guidedBreathingVoice } = useSettingsStore(); + const { guidedBreathingVoice, healthConnectEnabled } = useSettingsStore(); + const setHealthConnectEnabled = useSettingsStore((state) => state.setHealthConnectEnabled); const screenReaderEnabled = useScreenReaderEnabled(); // A user of a screen reader who disabled the voice has no channel that works // without sight, because the visuals carry the whole exercise. The voice @@ -63,10 +87,17 @@ export const ExerciseScreen: FC(undefined); + const completedHealthConnectSegments = useRef([]); + const isMounted = useRef(true); + const sessionStatus = useRef(session.status); + const healthConnectSessionId = useRef(`breathly-${Date.now()}`).current; const insets = useSafeAreaInsets(); const colorScheme = useColorScheme(); const theme = useThemeColors(); + sessionStatus.current = session.status; + const { playExerciseStepAudio, playExerciseCompletedAudio, stopExerciseAudio } = useExerciseAudio( effectiveGuidedBreathingVoice, ); @@ -78,8 +109,21 @@ export const ExerciseScreen: FC subscription.remove(); - }, [stopExerciseAudio]); + }, [healthConnectSessionId, stopExerciseAudio]); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); const handleInterludeComplete = useCallback(() => { + activeHealthConnectSegmentStartedAtMs.current = Date.now(); + completedHealthConnectSegments.current = []; dispatchSession({ type: "start" }); }, []); @@ -104,10 +157,49 @@ export const ExerciseScreen: FC { - playExerciseCompletedAudio(); - dispatchSession({ type: "complete", activeElapsedMs: activeElapsedMs.current }); - }, [playExerciseCompletedAudio]); + const handleExerciseComplete = useCallback( + (completedAtMs: number) => { + if (sessionStatus.current !== "running") return; + + playExerciseCompletedAudio(); + const completedActiveElapsedMs = activeElapsedMs.current; + dispatchSession({ + type: "complete", + activeElapsedMs: completedActiveElapsedMs, + }); + const startedAtMs = activeHealthConnectSegmentStartedAtMs.current; + const segments = appendCompletedHealthConnectSegment( + completedHealthConnectSegments.current, + startedAtMs, + completedAtMs, + healthConnectSessionId, + ); + if (healthConnectEnabled && startedAtMs != null && completedAtMs <= startedAtMs) { + if (isMounted.current) { + Alert.alert("Health Connect", healthConnectSaveErrorMessage); + } + } + if (healthConnectEnabled && segments.length > 0) { + void Promise.all(segments.map(saveCompletedBreathingSession)).then((results) => { + const failure = results.find((result) => result !== "saved"); + if (!failure) return; + + if (healthConnectPermanentFailures.has(failure)) { + setHealthConnectEnabled(false); + } + const message = healthConnectFailureMessages[failure]; + if (!message || !isMounted.current) return; + Alert.alert("Health Connect", message); + }); + } + }, + [ + healthConnectEnabled, + healthConnectSessionId, + playExerciseCompletedAudio, + setHealthConnectEnabled, + ], + ); const handleStepIndexChange = useCallback((stepIndex: number) => { dispatchSession({ type: "stepChanged", stepIndex }); @@ -118,8 +210,11 @@ export const ExerciseScreen: FC { + if (session.resumeStatus === "running") { + activeHealthConnectSegmentStartedAtMs.current = Date.now(); + } dispatchSession({ type: "resume" }); - }, []); + }, [session.resumeStatus]); return ( { }; interface ExerciseRunningFragmentProps { - onComplete: () => unknown; + onComplete: (completedAtMs: number) => unknown; onStepChange: (stepMetadata: StepMetadata) => unknown; onStepIndexChange: (stepIndex: number) => void; initialActiveElapsedMs: number; @@ -225,12 +320,13 @@ const ExerciseRunningFragment: FC = ({ const startCompletion = () => { if (completionStartedRef.current) return; completionStartedRef.current = true; + const completedAtMs = Date.now(); animate(unmountContentAnimVal, { toValue: 0, duration: unmountAnimDuration, }).start(({ finished }) => { if (finished) { - onComplete(); + onComplete(completedAtMs); } }); }; diff --git a/src/screens/exercise-screen/health-connect-session.ts b/src/screens/exercise-screen/health-connect-session.ts new file mode 100644 index 0000000..a73476f --- /dev/null +++ b/src/screens/exercise-screen/health-connect-session.ts @@ -0,0 +1,19 @@ +import type { CompletedBreathingSession } from "@breathly/services/health-connect"; + +export const appendCompletedHealthConnectSegment = ( + segments: CompletedBreathingSession[], + startedAtMs: number | undefined, + completedAtMs: number, + sessionId: string, +): CompletedBreathingSession[] => { + if (startedAtMs == null || completedAtMs <= startedAtMs) return segments; + + return [ + ...segments, + { + startedAtMs, + completedAtMs, + clientRecordId: `${sessionId}-${segments.length}`, + }, + ]; +}; diff --git a/src/screens/settings-screen/__tests__/use-health-connect-setting.test.ts b/src/screens/settings-screen/__tests__/use-health-connect-setting.test.ts new file mode 100644 index 0000000..c33e636 --- /dev/null +++ b/src/screens/settings-screen/__tests__/use-health-connect-setting.test.ts @@ -0,0 +1,140 @@ +import { act, renderHook } from "@testing-library/react-native"; +import { Alert } from "react-native"; +import { + getHealthConnectStatus, + requestHealthConnectPermission, +} from "@breathly/services/health-connect"; +import { useHealthConnectSetting } from "../use-health-connect-setting"; + +jest.mock("@breathly/services/health-connect", () => ({ + getHealthConnectStatus: jest.fn(), + requestHealthConnectPermission: jest.fn(), +})); + +const mockGetHealthConnectStatus = jest.mocked(getHealthConnectStatus); +const mockRequestHealthConnectPermission = jest.mocked(requestHealthConnectPermission); + +const createDeferred = () => { + let resolve: (value: Value) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve: resolve! }; +}; + +describe("useHealthConnectSetting", () => { + const setHealthConnectEnabled = jest.fn(); + const alertSpy = jest.spyOn(Alert, "alert").mockImplementation(() => undefined); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + alertSpy.mockRestore(); + }); + + it("enables the setting after Health Connect authorizes it", async () => { + mockGetHealthConnectStatus.mockResolvedValue("authorized"); + const { result } = await renderHook(() => useHealthConnectSetting(setHealthConnectEnabled)); + + await act(async () => { + await result.current(true); + }); + + expect(setHealthConnectEnabled).toHaveBeenCalledWith(true); + expect(mockRequestHealthConnectPermission).not.toHaveBeenCalled(); + }); + + it("does not leave the toggle locked after duplicate enable taps", async () => { + const firstStatus = createDeferred<"authorized">(); + mockGetHealthConnectStatus + .mockReturnValueOnce(firstStatus.promise) + .mockResolvedValue("authorized"); + const { result } = await renderHook(() => useHealthConnectSetting(setHealthConnectEnabled)); + + const firstEnable = result.current(true); + const duplicateEnable = result.current(true); + firstStatus.resolve("authorized"); + await act(async () => { + await Promise.all([firstEnable, duplicateEnable]); + await result.current(true); + }); + + expect(mockGetHealthConnectStatus).toHaveBeenCalledTimes(2); + expect(setHealthConnectEnabled).toHaveBeenCalledWith(true); + }); + + it("uses the latest enable intent when access resolves after an off/on sequence", async () => { + const deferredStatus = createDeferred<"authorized">(); + mockGetHealthConnectStatus + .mockReturnValueOnce(deferredStatus.promise) + .mockResolvedValue("authorized"); + const { result } = await renderHook(() => useHealthConnectSetting(setHealthConnectEnabled)); + + const enable = result.current(true).catch((error: unknown) => { + throw error; + }); + await act(async () => { + await result.current(false); + }); + const enableAgain = result.current(true); + deferredStatus.resolve("authorized"); + await act(async () => { + await enable; + await enableAgain; + }); + + expect(setHealthConnectEnabled).toHaveBeenNthCalledWith(1, false); + expect(setHealthConnectEnabled).toHaveBeenLastCalledWith(true); + expect(mockGetHealthConnectStatus).toHaveBeenCalledTimes(1); + }); + + it("keeps the setting disabled and explains a denied permission request", async () => { + mockGetHealthConnectStatus.mockResolvedValue("permissionRequired"); + mockRequestHealthConnectPermission.mockResolvedValue(false); + const { result } = await renderHook(() => useHealthConnectSetting(setHealthConnectEnabled)); + + await act(async () => { + await result.current(true); + }); + + expect(setHealthConnectEnabled).toHaveBeenCalledWith(false); + expect(alertSpy).toHaveBeenCalledWith( + "Health Connect permission needed", + "Allow Breathly to write mindfulness sessions to use this option.", + ); + }); + + it("enables the setting when the permission request succeeds", async () => { + mockGetHealthConnectStatus.mockResolvedValue("permissionRequired"); + mockRequestHealthConnectPermission.mockResolvedValue(true); + const { result } = await renderHook(() => useHealthConnectSetting(setHealthConnectEnabled)); + + await act(async () => { + await result.current(true); + }); + + expect(setHealthConnectEnabled).toHaveBeenCalledWith(true); + expect(alertSpy).not.toHaveBeenCalled(); + }); + + it("does not show an alert after the settings screen unmounts", async () => { + const deferredStatus = createDeferred<"unavailable">(); + mockGetHealthConnectStatus.mockReturnValue(deferredStatus.promise); + const { result, unmount } = await renderHook(() => + useHealthConnectSetting(setHealthConnectEnabled), + ); + + const enable = result.current(true).catch((error: unknown) => { + throw error; + }); + await unmount(); + deferredStatus.resolve("unavailable"); + await act(async () => { + await enable; + }); + + expect(alertSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/screens/settings-screen/settings-screen.tsx b/src/screens/settings-screen/settings-screen.tsx index 9a2675a..d990633 100644 --- a/src/screens/settings-screen/settings-screen.tsx +++ b/src/screens/settings-screen/settings-screen.tsx @@ -18,6 +18,7 @@ import { type Theme, } from "@breathly/stores/settings-state"; import { GuidedBreathingMode } from "@breathly/types/guided-breathing-mode"; +import { useHealthConnectSetting } from "./use-health-connect-setting"; export const SettingsRootScreen: FC< NativeStackScreenProps @@ -38,6 +39,9 @@ export const SettingsRootScreen: FC< const setTheme = useSettingsStore((state) => state.setTheme); const vibrationEnabled = useSettingsStore((state) => state.vibrationEnabled); const setVibrationEnabled = useSettingsStore((state) => state.setVibrationEnabled); + const healthConnectEnabled = useSettingsStore((state) => state.healthConnectEnabled); + const setHealthConnectEnabled = useSettingsStore((state) => state.setHealthConnectEnabled); + const handleHealthConnectChange = useHealthConnectSetting(setHealthConnectEnabled); React.useEffect(() => { // Use `setOptions` to update the button that we previously specified @@ -134,6 +138,19 @@ export const SettingsRootScreen: FC< testID="settings.vibration" /> + {Platform.OS === "android" && ( + + void handleHealthConnectChange(enabled)} + testID="settings.health-connect" + /> + + )} unknown; + +const unavailableMessages = { + unavailable: "Health Connect is not available on this device.", + updateRequired: "Install or update Health Connect before enabling this option.", + unsupported: "This version of Health Connect cannot record mindfulness sessions.", +} as const; + +export const useHealthConnectSetting = (setHealthConnectEnabled: SetHealthConnectEnabled) => { + const requestInProgress = useRef(false); + const desiredEnabled = useRef(false); + const isMounted = useRef(true); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + return useCallback( + async (enabled: boolean) => { + desiredEnabled.current = enabled; + if (!enabled) { + setHealthConnectEnabled(false); + return; + } + if (requestInProgress.current) return; + + requestInProgress.current = true; + try { + const status = await getHealthConnectStatus(); + if (!desiredEnabled.current) return; + + if (status === "authorized") { + setHealthConnectEnabled(true); + return; + } + if (status === "permissionRequired") { + const granted = await requestHealthConnectPermission(); + if (!desiredEnabled.current) return; + + setHealthConnectEnabled(granted); + if (!granted && isMounted.current) { + Alert.alert( + "Health Connect permission needed", + "Allow Breathly to write mindfulness sessions to use this option.", + ); + } + return; + } + + if (isMounted.current) { + Alert.alert("Health Connect unavailable", unavailableMessages[status]); + } + } catch (error) { + console.warn("[health-connect] could not request access", error); + if (desiredEnabled.current && isMounted.current) { + Alert.alert( + "Health Connect unavailable", + "Breathly could not connect to Health Connect.", + ); + } + } finally { + requestInProgress.current = false; + } + }, + [setHealthConnectEnabled], + ); +}; diff --git a/src/services/__tests__/health-connect.test.ts b/src/services/__tests__/health-connect.test.ts new file mode 100644 index 0000000..e13f7ea --- /dev/null +++ b/src/services/__tests__/health-connect.test.ts @@ -0,0 +1,191 @@ +const mockGetStatusAsync = jest.fn(); +const mockRequestPermissionAsync = jest.fn(); +const mockWriteBreathingSessionAsync = jest.fn(); + +const mockHealthConnectModule = { + getStatusAsync: mockGetStatusAsync, + requestPermissionAsync: mockRequestPermissionAsync, + writeBreathingSessionAsync: mockWriteBreathingSessionAsync, +}; + +let healthConnect: typeof import("../health-connect"); + +describe("Health Connect", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + jest.doMock("expo-modules-core", () => ({ + ...jest.requireActual("expo-modules-core"), + requireOptionalNativeModule: () => mockHealthConnectModule, + })); + healthConnect = require("../health-connect") as typeof import("../health-connect"); + }); + + it.each([ + "unavailable", + "updateRequired", + "unsupported", + "permissionRequired", + "authorized", + ] as const)("returns the native %s status", async (status) => { + mockGetStatusAsync.mockResolvedValue(status); + + await expect(healthConnect.getHealthConnectStatus()).resolves.toBe(status); + }); + + it("treats an unknown native status as unavailable", async () => { + mockGetStatusAsync.mockResolvedValue("unexpected"); + + await expect(healthConnect.getHealthConnectStatus()).resolves.toBe("unavailable"); + }); + + it("is unavailable when the Android module is not linked", async () => { + jest.resetModules(); + jest.doMock("expo-modules-core", () => ({ + ...jest.requireActual("expo-modules-core"), + requireOptionalNativeModule: () => null, + })); + const unavailableHealthConnect = + require("../health-connect") as typeof import("../health-connect"); + + await expect(unavailableHealthConnect.getHealthConnectStatus()).resolves.toBe("unavailable"); + await expect(unavailableHealthConnect.requestHealthConnectPermission()).resolves.toBe(false); + await expect( + unavailableHealthConnect.saveCompletedBreathingSession({ + startedAtMs: 1_000, + completedAtMs: 10_000, + clientRecordId: "session", + }), + ).resolves.toBe("unavailable"); + }); + + it("returns the result of the write-permission request", async () => { + mockRequestPermissionAsync.mockResolvedValue(true); + + await expect(healthConnect.requestHealthConnectPermission()).resolves.toBe(true); + }); + + it("writes the session's wall-clock interval", async () => { + mockWriteBreathingSessionAsync.mockResolvedValue("saved"); + + await expect( + healthConnect.saveCompletedBreathingSession({ + startedAtMs: 42_000, + completedAtMs: 100_000, + clientRecordId: "breathly-session-1", + }), + ).resolves.toBe("saved"); + expect(mockWriteBreathingSessionAsync).toHaveBeenCalledWith( + 42_000, + 100_000, + "breathly-session-1", + ); + }); + + it("writes every active interval of an exercise resumed after the background", async () => { + mockWriteBreathingSessionAsync.mockResolvedValue("saved"); + + await Promise.all([ + healthConnect.saveCompletedBreathingSession({ + startedAtMs: 42_000, + completedAtMs: 60_000, + clientRecordId: "breathly-session-1-0", + }), + healthConnect.saveCompletedBreathingSession({ + startedAtMs: 80_000, + completedAtMs: 100_000, + clientRecordId: "breathly-session-1-1", + }), + ]); + + expect(mockWriteBreathingSessionAsync).toHaveBeenNthCalledWith( + 1, + 42_000, + 60_000, + "breathly-session-1-0", + ); + expect(mockWriteBreathingSessionAsync).toHaveBeenNthCalledWith( + 2, + 80_000, + 100_000, + "breathly-session-1-1", + ); + }); + + it("passes through a lost Health Connect permission", async () => { + mockWriteBreathingSessionAsync.mockResolvedValue("permissionRequired"); + + await expect( + healthConnect.saveCompletedBreathingSession({ + startedAtMs: 42_000, + completedAtMs: 100_000, + clientRecordId: "breathly-session-1", + }), + ).resolves.toBe("permissionRequired"); + }); + + it("treats an unknown write result as an error", async () => { + mockWriteBreathingSessionAsync.mockResolvedValue("unexpected"); + + await expect( + healthConnect.saveCompletedBreathingSession({ + startedAtMs: 42_000, + completedAtMs: 100_000, + clientRecordId: "breathly-session-1", + }), + ).resolves.toBe("error"); + }); + + it.each([ + { startedAtMs: -1, completedAtMs: 100_000, clientRecordId: "session" }, + { + startedAtMs: Number.NaN, + completedAtMs: 100_000, + clientRecordId: "session", + }, + { + startedAtMs: 100_000, + completedAtMs: 100_000, + clientRecordId: "session", + }, + { + startedAtMs: 100_001, + completedAtMs: 100_000, + clientRecordId: "session", + }, + { + startedAtMs: 1_000, + completedAtMs: Number.NaN, + clientRecordId: "session", + }, + { startedAtMs: 1_000, completedAtMs: 100_000, clientRecordId: "" }, + { + startedAtMs: 1_000, + completedAtMs: 100_000, + clientRecordId: undefined as never, + }, + ])("does not send an invalid session: %p", async (session) => { + await expect(healthConnect.saveCompletedBreathingSession(session)).resolves.toBe("error"); + expect(mockWriteBreathingSessionAsync).not.toHaveBeenCalled(); + }); + + it("does not interrupt completion when Health Connect rejects the write", async () => { + const error = new Error("provider stopped"); + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined); + mockWriteBreathingSessionAsync.mockRejectedValue(error); + + await expect( + healthConnect.saveCompletedBreathingSession({ + startedAtMs: 1_000, + completedAtMs: 10_000, + clientRecordId: "session", + }), + ).resolves.toBe("error"); + expect(warnSpy).toHaveBeenCalledWith( + "[health-connect] could not save the completed breathing session", + error, + ); + + warnSpy.mockRestore(); + }); +}); diff --git a/src/services/health-connect.ts b/src/services/health-connect.ts new file mode 100644 index 0000000..9cc9400 --- /dev/null +++ b/src/services/health-connect.ts @@ -0,0 +1,83 @@ +import { requireOptionalNativeModule } from "expo-modules-core"; + +export type HealthConnectStatus = + | "unavailable" + | "updateRequired" + | "unsupported" + | "permissionRequired" + | "authorized"; + +interface BreathlyHealthConnectNativeModule { + getStatusAsync: () => Promise; + requestPermissionAsync: () => Promise; + writeBreathingSessionAsync: ( + startTimeMs: number, + endTimeMs: number, + clientRecordId: string, + ) => Promise; +} + +export interface CompletedBreathingSession { + startedAtMs: number; + completedAtMs: number; + clientRecordId: string; +} + +export type HealthConnectWriteResult = HealthConnectStatus | "saved" | "error"; + +const healthConnectModule = + requireOptionalNativeModule("BreathlyHealthConnect"); + +const validStatuses: HealthConnectStatus[] = [ + "unavailable", + "updateRequired", + "unsupported", + "permissionRequired", + "authorized", +]; + +const isValidStatus = (value: unknown): value is HealthConnectStatus => + validStatuses.includes(value as HealthConnectStatus); + +const isValidWriteResult = (value: unknown): value is HealthConnectWriteResult => + value === "saved" || value === "error" || isValidStatus(value); + +export const getHealthConnectStatus = async (): Promise => { + if (!healthConnectModule) return "unavailable"; + const status = await healthConnectModule.getStatusAsync(); + return isValidStatus(status) ? status : "unavailable"; +}; + +export const requestHealthConnectPermission = async () => + healthConnectModule ? healthConnectModule.requestPermissionAsync() : false; + +export const saveCompletedBreathingSession = async ({ + startedAtMs, + completedAtMs, + clientRecordId, +}: CompletedBreathingSession): Promise => { + if (!healthConnectModule) return "unavailable"; + + if ( + !Number.isFinite(startedAtMs) || + startedAtMs < 0 || + !Number.isFinite(completedAtMs) || + completedAtMs <= startedAtMs || + typeof clientRecordId !== "string" || + clientRecordId.length === 0 + ) { + return "error"; + } + + try { + const result = await healthConnectModule.writeBreathingSessionAsync( + startedAtMs, + completedAtMs, + clientRecordId, + ); + return isValidWriteResult(result) ? result : "error"; + } catch (error) { + console.warn("[health-connect] could not save the completed breathing session", error); + return "error"; + } +}; diff --git a/src/stores/__tests__/settings-state.test.ts b/src/stores/__tests__/settings-state.test.ts index e4e52ea..092f272 100644 --- a/src/stores/__tests__/settings-state.test.ts +++ b/src/stores/__tests__/settings-state.test.ts @@ -23,6 +23,7 @@ describe("settings state", () => { customPatternEnabled: true, customPatternSteps: [1_500, 0, 8_000, 3_500] as [number, number, number, number], guidedBreathingVoice: "bell" as const, + healthConnectEnabled: true, timeLimit: 0, shouldFollowSystemDarkMode: false, theme: "dark" as const, @@ -38,6 +39,7 @@ describe("settings state", () => { customPatternSteps: [-1, Number.NaN, 200_000, 3_000], selectedPatternPresetId: "missing-preset", guidedBreathingVoice: "missing-voice", + healthConnectEnabled: "yes", timeLimit: Number.POSITIVE_INFINITY, shouldFollowSystemDarkMode: null, theme: "sepia", diff --git a/src/stores/__tests__/settings.test.ts b/src/stores/__tests__/settings.test.ts index 1a0580f..6f730a1 100644 --- a/src/stores/__tests__/settings.test.ts +++ b/src/stores/__tests__/settings.test.ts @@ -62,14 +62,18 @@ describe("settings persistence", () => { }); it("restores stored settings and keeps the store actions", async () => { - mockGetItem.mockResolvedValue(storedSettings({ theme: "dark", vibrationEnabled: false })); + mockGetItem.mockResolvedValue( + storedSettings({ theme: "dark", vibrationEnabled: false, healthConnectEnabled: true }), + ); const useSettingsStore = await loadSettingsStore(); expect(useSettingsStore.persist.hasHydrated()).toBe(true); expect(useSettingsStore.getState().theme).toBe("dark"); expect(useSettingsStore.getState().vibrationEnabled).toBe(false); + expect(useSettingsStore.getState().healthConnectEnabled).toBe(true); expect(typeof useSettingsStore.getState().setTheme).toBe("function"); + expect(typeof useSettingsStore.getState().setHealthConnectEnabled).toBe("function"); }); it("retries a read that failed before it falls back to the defaults", async () => { diff --git a/src/stores/settings-state.ts b/src/stores/settings-state.ts index 9b43505..7df36e2 100644 --- a/src/stores/settings-state.ts +++ b/src/stores/settings-state.ts @@ -11,6 +11,7 @@ export interface PersistedSettingsState { customPatternSteps: CustomPatternSteps; selectedPatternPresetId: string; guidedBreathingVoice: GuidedBreathingMode; + healthConnectEnabled: boolean; timeLimit: number; shouldFollowSystemDarkMode: boolean; theme: Theme; @@ -39,6 +40,7 @@ export const defaultSettingsState: PersistedSettingsState = { customPatternSteps: [ms("4 sec"), ms("2 sec"), ms("4 sec"), ms("2 sec")], selectedPatternPresetId: "square", guidedBreathingVoice: "paul", + healthConnectEnabled: false, timeLimit: ms("2 min"), shouldFollowSystemDarkMode: true, theme: "light", @@ -110,6 +112,10 @@ export const normalizePersistedSettingsState = (value: unknown): PersistedSettin customPatternSteps, selectedPatternPresetId, guidedBreathingVoice, + healthConnectEnabled: + typeof candidate.healthConnectEnabled === "boolean" + ? candidate.healthConnectEnabled + : defaultSettingsState.healthConnectEnabled, timeLimit: clampFiniteNumber( candidate.timeLimit, 0, diff --git a/src/stores/settings.ts b/src/stores/settings.ts index a140f05..acd73d4 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -25,6 +25,7 @@ interface SettingsStore extends PersistedSettingsState { setCustomPatternStep: (stepIndex: number, stepValue: number) => unknown; setSelectedPatternPresetId: (patternPresetId: string) => unknown; setGuidedBreathingVoice: (guidedBreathingVoice: GuidedBreathingMode) => unknown; + setHealthConnectEnabled: (healthConnectEnabled: boolean) => unknown; increaseTimeLimit: () => unknown; decreaseTimeLimit: () => unknown; setShouldFollowSystemDarkMode: (shouldFollowSystemDarkMode: boolean) => unknown; @@ -101,6 +102,7 @@ export const useSettingsStore = create()( }, setSelectedPatternPresetId: (selectedPatternPresetId) => set({ selectedPatternPresetId }), setGuidedBreathingVoice: (guidedBreathingVoice) => set({ guidedBreathingVoice }), + setHealthConnectEnabled: (healthConnectEnabled) => set({ healthConnectEnabled }), increaseTimeLimit: () => set({ timeLimit: adjustTimeLimit(get().timeLimit, timeLimitStepMs) }), decreaseTimeLimit: () =>