From 036e469b385ac41d18fcc7c49b7a386f29f47f67 Mon Sep 17 00:00:00 2001 From: Michael Lustig Date: Thu, 21 Mar 2024 16:16:35 -0400 Subject: [PATCH] Got my simple bluetooth permission actor working --- .../src/lib/permission-logic.spec.ts | 558 ++++++++++-------- .../src/lib/playground.spec.ts | 30 +- 2 files changed, 340 insertions(+), 248 deletions(-) diff --git a/libs/permissions/permissionLogic/src/lib/permission-logic.spec.ts b/libs/permissions/permissionLogic/src/lib/permission-logic.spec.ts index 408cdbd..a9ad89d 100644 --- a/libs/permissions/permissionLogic/src/lib/permission-logic.spec.ts +++ b/libs/permissions/permissionLogic/src/lib/permission-logic.spec.ts @@ -1,90 +1,4 @@ // permissionMonitoringMachine.test.ts - -describe('Permission Monitoring Machine', () => { - it('should start in application foregrounded state', () => { - const permissionMonitoringActor = createActor(permissionMonitoringMachine); - permissionMonitoringActor.start(); - - expect(permissionMonitoringActor.getSnapshot().value).toEqual({ - applicationLifecycle: 'application is in foreground', - permissionChecking: {}, - }); - }); - - it.skip('should check permissions once invoked', async () => { - const permissionMonitoringActor = createActor(permissionMonitoringMachine); - const initialPermissionMap: PermissionStatusMapType = { - bluetooth: 'unasked', - microphone: 'unasked', - }; - const expectedFinalPermissionMap: PermissionStatusMapType = { - bluetooth: 'denied', - microphone: 'denied', - }; - - expect(permissionMonitoringActor.getSnapshot().context).toStrictEqual({ - permissionStatuses: initialPermissionMap, - }); - - permissionMonitoringActor.start(); - - await waitFor( - permissionMonitoringActor, - (state) => { - console.log(state.context.permissionStatuses); - state.context.permissionStatuses; - return ( - state.context.permissionStatuses.bluetooth === 'denied' && - state.context.permissionStatuses.microphone === 'denied' - ); - }, - { timeout: 100 } - ); - - // permissionMonitoringActor.getSnapshot().context; - - // expect(permissionMonitoringActor.getSnapshot().context).toStrictEqual({ - // permissionStatuses: expectedFinalPermissionMap, - // }); - }); - - it.skip('should request permission when asked to do so', async () => { - const permissionMonitoringActor = createActor(permissionMonitoringMachine); - permissionMonitoringActor.start(); - - await waitFor( - permissionMonitoringActor, - (state) => { - return ( - state.context.permissionStatuses.bluetooth === - PermissionStatuses.denied && - state.context.permissionStatuses.microphone === - PermissionStatuses.denied - ); - }, - { - timeout: /* speeds up tests, no need to wait any real amount of time, just a tick */ 0, - } - ); - - permissionMonitoringActor.send({ - type: 'triggerPermissionRequest', - permission: Permissions.microphone, - }); - }); -}); - -import { - assign, - createActor, - fromCallback, - sendParent, - sendTo, - setup, - waitFor, - raise, -} from 'xstate'; - export const Permissions = { bluetooth: 'bluetooth', microphone: 'microphone', @@ -97,6 +11,18 @@ export const PermissionStatuses = { revoked: 'revoked', blocked: 'blocked', } as const; +const ApplicationLifecycleEvents = { + applicationForegrounded: 'applicationForegrounded', + applicationBackgrounded: 'applicationBackgrounded', +} as const; + +type ApplicationLifecycleEvent = + (typeof ApplicationLifecycleEvents)[keyof typeof ApplicationLifecycleEvents]; + +interface PermissionMachineActions { + checkPermission: () => Promise; + requestPermission: () => Promise; +} export type PermissionStatus = (typeof PermissionStatuses)[keyof typeof PermissionStatuses]; @@ -138,23 +64,86 @@ type PermissionMonitoringMachineEvents = | { type: 'applicationForegrounded' } | { type: 'applicationBackgrounded' }; -const ApplicationLifecycleEvents = { - applicationForegrounded: 'applicationForegrounded', - applicationBackgrounded: 'applicationBackgrounded', -} as const; +// describe('Permission Monitoring Machine', () => { +// it('should start in application foregrounded state', () => { +// const permissionMonitoringActor = createActor(permissionMonitoringMachine); +// permissionMonitoringActor.start(); -type ApplicationLifecycleEvent = - (typeof ApplicationLifecycleEvents)[keyof typeof ApplicationLifecycleEvents]; +// expect(permissionMonitoringActor.getSnapshot().value).toEqual({ +// applicationLifecycle: 'application is in foreground', +// permissionChecking: {}, +// }); +// }); -interface PermissionMachineActions { - checkPermission: () => Promise; - requestPermission: () => Promise; -} +// it.skip('should check permissions once invoked', async () => { +// const permissionMonitoringActor = createActor(permissionMonitoringMachine); +// const initialPermissionMap: PermissionStatusMapType = { +// bluetooth: 'unasked', +// microphone: 'unasked', +// }; +// const expectedFinalPermissionMap: PermissionStatusMapType = { +// bluetooth: 'denied', +// microphone: 'denied', +// }; + +// expect(permissionMonitoringActor.getSnapshot().context).toStrictEqual({ +// permissionStatuses: initialPermissionMap, +// }); + +// permissionMonitoringActor.start(); + +// await waitFor( +// permissionMonitoringActor, +// (state) => { +// console.log(state.context.permissionStatuses); +// state.context.permissionStatuses; +// return ( +// state.context.permissionStatuses.bluetooth === 'denied' && +// state.context.permissionStatuses.microphone === 'denied' +// ); +// }, +// { timeout: 100 } +// ); + +// // permissionMonitoringActor.getSnapshot().context; + +// // expect(permissionMonitoringActor.getSnapshot().context).toStrictEqual({ +// // permissionStatuses: expectedFinalPermissionMap, +// // }); +// }); + +// it.skip('should request permission when asked to do so', async () => { +// const permissionMonitoringActor = createActor(permissionMonitoringMachine); +// permissionMonitoringActor.start(); + +// await waitFor( +// permissionMonitoringActor, +// (state) => { +// return ( +// state.context.permissionStatuses.bluetooth === +// PermissionStatuses.denied && +// state.context.permissionStatuses.microphone === +// PermissionStatuses.denied +// ); +// }, +// { +// timeout: /* speeds up tests, no need to wait any real amount of time, just a tick */ 0, +// } +// ); + +// permissionMonitoringActor.send({ +// type: 'triggerPermissionRequest', +// permission: Permissions.microphone, +// }); +// }); +// }); + +import { assign, createActor, fromPromise, log, setup, waitFor } from 'xstate'; const unimplementedPermissionMachineActions: PermissionMachineActions = { checkPermission: () => { console.log('checkPermission'); - return new Promise((resolve) => resolve(PermissionStatuses.denied)); + return new Promise((resolve) => resolve(PermissionStatuses.granted)); throw new Error('unimplemented'); }, @@ -164,172 +153,251 @@ const unimplementedPermissionMachineActions: PermissionMachineActions = { }, } as const; -type PermissionMachineEvents = - | { type: 'triggerPermissionCheck' } - | { type: 'triggerPermissionRequest'; permission: Permission }; +type PermissionMachineEvents = { type: 'triggerPermissionCheck' }; +// | { type: 'triggerPermissionRequest'; permission: Permission }; + +describe('bluetooth permission machine', () => { + it('should request permission', async () => { + const bluetoothPermissionActor = createActor( + bluetoothPermissionMachine + ).start(); + console.log( + JSON.stringify(bluetoothPermissionActor.getSnapshot().value, null, 2) + ); + + expect(bluetoothPermissionActor.getSnapshot().context.result).toBe( + PermissionStatuses.unasked + ); + bluetoothPermissionActor.send({ type: 'triggerPermissionCheck' }); + console.log( + JSON.stringify(bluetoothPermissionActor.getSnapshot().value, null, 2) + ); + console.log( + JSON.stringify(bluetoothPermissionActor.getSnapshot().context, null, 2) + ); + + await waitFor( + bluetoothPermissionActor, + (state) => state.context.result !== PermissionStatuses.unasked, + { timeout: 10 } + ); + + console.log( + JSON.stringify(bluetoothPermissionActor.getSnapshot().value, null, 2) + ); + console.log( + JSON.stringify(bluetoothPermissionActor.getSnapshot().context, null, 2) + ); + expect(bluetoothPermissionActor.getSnapshot().value).toBe( + 'permissionGranted' + ); + }); +}); const bluetoothPermissionMachine = setup({ types: { + context: {} as { result: PermissionStatus }, input: {} as { permission: Permission }, events: {} as PermissionMachineEvents, }, - actions: { - // checkPermission: unimplementedPermissionMachineActions.checkPermission, - checkPermission: ({ context, event }) => { - console.log('checkPermission', { event }); - - sendParent({ - type: 'permissionChecked', - permission: Permissions.bluetooth, - status: PermissionStatuses.denied, - }); - }, - requestPermission: unimplementedPermissionMachineActions.requestPermission, - }, -}).createMachine({ - id: 'bluetoothPermissionActor', - on: { - triggerPermissionCheck: { - actions: [ - 'checkPermission', - () => console.log('checkPermission-----------'), - ], - }, - triggerPermissionRequest: { - actions: 'requestPermission', - }, - }, -}); -// can i come bck from the background and cache the permission check -// head honcho -const permissionMonitoringMachine = setup({ - types: { - context: {} as PermissionMonitoringMachineContext, - events: {} as PermissionMonitoringMachineEvents, - }, - actions: { - triggerPermissionCheck: sendTo('bluetoothPermissionActor', { - type: 'triggerPermissionCheck', - permission: Permissions.bluetooth, - }), - // triggerPermissionRequest: (_, params: { permission: Permission }) => - // sendTo('permissionRequestActor', { - // type: 'requestPermission', - // permission: params.permission, - // }), - }, actors: { - subscribeToApplicationLifecycleEvents: fromCallback( - ({ sendBack, receive }) => { - // ... - // i have to have a default implementation here... what should it be? - // I'm leaning towards unimplemented to avoid confusion - /* - can't "forward" input to child actor... - - input.subscribeToApplicationLifecycleEvents((event) => { - if (event === 'applicationForegrounded') { - sendBack({ type: 'applicationForegrounded' }); - } else if (event === 'applicationBackgrounded') { - sendBack({ type: 'applicationBackgrounded' }); - } - }); - */ - } - ), - bluetoothPermissionActor: bluetoothPermissionMachine, - - microphonePermissionActor: fromCallback(({ sendBack, receive }) => { - const checkPermission = (): Promise => { - return Promise.resolve(PermissionStatuses.granted); - }; - - const requestPermission = (): Promise => { - return Promise.resolve(PermissionStatuses.granted); - }; - - receive(async (event) => { - if (event.type === 'checkPermissions') { - const result = await checkPermission(); - - sendBack({ - type: 'permissionChecked', - permission: Permissions.microphone, - status: result, - }); - } else if (event.type === 'requestPermission') { - // const result = await requestPermission(); - // sendBack({ - // type: 'permissionChecked', - // permission: Permissions.bluetooth, - // status: result, - // }); - } - }); + checkPermission: fromPromise(async () => { + const result = + await unimplementedPermissionMachineActions.checkPermission(); + console.log(JSON.stringify(result, null, 2)); + + return result; }), }, + guards: { + isPermissionDenied: ({ context, event }) => { + console.log(JSON.stringify(event, null, 2)); + + console.log(JSON.stringify(context, null, 2)); + + const isDenied = event.output == PermissionStatuses.denied; + console.log(JSON.stringify(isDenied, null, 2)); + return isDenied; + }, + isPermissionGranted: ({ context, event }) => { + console.log(JSON.stringify(context, null, 2)); + const isGranted = event.output === PermissionStatuses.granted; + console.log(JSON.stringify(isGranted, null, 2)); + return isGranted; + }, + }, }).createMachine({ - type: 'parallel', - context: { permissionStatuses: PermissionStatusMap }, + id: 'bluetoothPermissionActor', + context: { result: PermissionStatuses.unasked }, + initial: 'idle', states: { - applicationLifecycle: { - initial: ApplicationLifecycleStates.applicationInForeground, - states: { - [ApplicationLifecycleStates.applicationInForeground]: { - entry: [raise({ type: 'triggerPermissionCheck' })], - on: { - [ApplicationLifecycleEvents.applicationBackgrounded]: { - target: ApplicationLifecycleStates.applicationInBackground, - }, - }, - }, - [ApplicationLifecycleStates.applicationInBackground]: { - on: { - [ApplicationLifecycleEvents.applicationForegrounded]: { - target: ApplicationLifecycleStates.applicationInForeground, - }, - }, - }, + idle: { + on: { + triggerPermissionCheck: { target: 'checkingPermission' }, }, }, - permissionChecking: { + + permissionGranted: { + entry: log('permission granted'), + }, + permissionDenied: { + entry: log('permission denied'), + }, + + checkingPermission: { invoke: { - id: 'bluetoothPermissionActor', - src: bluetoothPermissionMachine, - }, - on: { - checkPermissions: { - actions: [ - sendTo('bluetoothPermissionActor', { - type: 'checkPermissions', + src: 'checkPermission', + onDone: [ + { + actions: assign({ + result: ({ event }) => event.output, }), - sendTo('microphonePermissionActor', { - type: 'checkPermissions', - }), - ], - }, - permissionChecked: { - actions: [ - assign({ - permissionStatuses: ({ context, event }) => ({ - ...context.permissionStatuses, - [event.permission]: event.status, - }), + target: 'permissionDenied', + guard: 'isPermissionDenied', + }, + { + actions: assign({ + result: ({ event }) => event.output, }), - ], - }, + target: 'permissionGranted', + guard: 'isPermissionGranted', + }, + ], }, }, }, - invoke: [ - { - src: 'subscribeToApplicationLifecycleEvents', - id: 'applicationLifecycleEventsSubscriber', - }, - { - id: 'microphonePermissionActor', - src: 'microphonePermissionActor', - }, - ], }); + +// can i come bck from the background and cache the permission check +// head honcho +// const permissionMonitoringMachine = setup({ +// types: { +// context: {} as PermissionMonitoringMachineContext, +// events: {} as PermissionMonitoringMachineEvents, +// }, +// actions: { +// triggerPermissionCheck: sendTo('bluetoothPermissionActor', { +// type: 'triggerPermissionCheck', +// permission: Permissions.bluetooth, +// }), +// // triggerPermissionRequest: (_, params: { permission: Permission }) => +// // sendTo('permissionRequestActor', { +// // type: 'requestPermission', +// // permission: params.permission, +// // }), +// }, +// actors: { +// subscribeToApplicationLifecycleEvents: fromCallback( +// ({ sendBack, receive }) => { +// // ... +// // i have to have a default implementation here... what should it be? +// // I'm leaning towards unimplemented to avoid confusion +// /* +// can't "forward" input to child actor... + +// input.subscribeToApplicationLifecycleEvents((event) => { +// if (event === 'applicationForegrounded') { +// sendBack({ type: 'applicationForegrounded' }); +// } else if (event === 'applicationBackgrounded') { +// sendBack({ type: 'applicationBackgrounded' }); +// } +// }); +// */ +// } +// ), +// bluetoothPermissionActor: bluetoothPermissionMachine, + +// microphonePermissionActor: fromCallback(({ sendBack, receive }) => { +// const checkPermission = (): Promise => { +// return Promise.resolve(PermissionStatuses.granted); +// }; + +// const requestPermission = (): Promise => { +// return Promise.resolve(PermissionStatuses.granted); +// }; + +// receive(async (event) => { +// if (event.type === 'checkPermissions') { +// const result = await checkPermission(); +// +// sendBack({ +// type: 'permissionChecked', +// permission: Permissions.microphone, +// status: result, +// }); +// } else if (event.type === 'requestPermission') { +// // const result = await requestPermission(); +// // sendBack({ +// // type: 'permissionChecked', +// // permission: Permissions.bluetooth, +// // status: result, +// // }); +// } +// }); +// }), +// }, +// }).createMachine({ +// type: 'parallel', +// context: { permissionStatuses: PermissionStatusMap }, +// states: { +// applicationLifecycle: { +// initial: ApplicationLifecycleStates.applicationInForeground, +// states: { +// [ApplicationLifecycleStates.applicationInForeground]: { +// entry: [raise({ type: 'triggerPermissionCheck' })], +// on: { +// [ApplicationLifecycleEvents.applicationBackgrounded]: { +// target: ApplicationLifecycleStates.applicationInBackground, +// }, +// }, +// }, +// [ApplicationLifecycleStates.applicationInBackground]: { +// on: { +// [ApplicationLifecycleEvents.applicationForegrounded]: { +// target: ApplicationLifecycleStates.applicationInForeground, +// }, +// }, +// }, +// }, +// }, +// permissionChecking: { +// invoke: { +// id: 'bluetoothPermissionActor', +// src: bluetoothPermissionMachine, +// }, +// on: { +// checkPermissions: { +// actions: [ +// sendTo('bluetoothPermissionActor', { +// type: 'checkPermissions', +// }), +// sendTo('microphonePermissionActor', { +// type: 'checkPermissions', +// }), +// ], +// }, +// permissionChecked: { +// actions: [ +// assign({ +// permissionStatuses: ({ context, event }) => ({ +// ...context.permissionStatuses, +// [event.permission]: event.status, +// }), +// }), +// ], +// }, +// }, +// }, +// }, +// invoke: [ +// { +// src: 'subscribeToApplicationLifecycleEvents', +// id: 'applicationLifecycleEventsSubscriber', +// }, +// { +// id: 'microphonePermissionActor', +// src: 'microphonePermissionActor', +// }, +// ], +// }); diff --git a/libs/permissions/permissionLogic/src/lib/playground.spec.ts b/libs/permissions/permissionLogic/src/lib/playground.spec.ts index ffc972c..620069f 100644 --- a/libs/permissions/permissionLogic/src/lib/playground.spec.ts +++ b/libs/permissions/permissionLogic/src/lib/playground.spec.ts @@ -3,10 +3,12 @@ import { assign, createActor, createMachine, + fromPromise, log, raise, sendParent, sendTo, + setup, } from 'xstate'; const composerMachine = createMachine({ @@ -1113,14 +1115,33 @@ describe('parallel states', () => { }); describe('invoke', () => { - it('child can immediately respond to the parent with multiple events', () => { - const bluetoothPermissionMachine = createMachine({ + it('should gradually become permissions actor system', () => { + const bluetoothPermissionMachine = setup({ + actors: { + checkPermission: fromPromise(async () => { + const result = await Promise.resolve('denied'); + return assign({ result }); + }), + }, + actions: { + spawnFetcher: assign(({ spawn }) => { + return { + child: spawn('checkPermission'), + }; + }), + }, types: {} as { events: | { type: 'FORWARD_DEC' } | { type: 'triggerPermissionRequest' }; + + context: { + triggered: boolean; + child: /*TODO fix type */ any | undefined; + }; }, - // id: 'bluetoothPermissionId', + }).createMachine({ + context: { triggered: false, child: undefined }, initial: 'init', states: { init: { @@ -1129,6 +1150,8 @@ describe('parallel states', () => { actions: [ log('triggerPermissionRequest'), assign({ triggered: true }), + sendTo('child', { type: 'triggerPermissionRequest' }), + // 'checkPermission', ], }, FORWARD_DEC: { @@ -1203,6 +1226,7 @@ describe('parallel states', () => { .context ).toEqual({ triggered: true, + result: 'something', }); }); });