Skip to content

Commit

Permalink
✨ (core): Event based device session refresher using rxjs
Browse files Browse the repository at this point in the history
  • Loading branch information
valpinkman committed Sep 2, 2024
1 parent d5ba61b commit bbbb7c1
Show file tree
Hide file tree
Showing 13 changed files with 143 additions and 93 deletions.
5 changes: 5 additions & 0 deletions .changeset/popular-spies-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/device-sdk-core": minor
---

Add new EventDispatcher and implement it in the device session refresher
3 changes: 2 additions & 1 deletion apps/sample/src/providers/DeviceSdkProvider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import {
ConsoleLogger,
DeviceSdk,
DeviceSdkBuilder,
LogLevel,
} from "@ledgerhq/device-sdk-core";

export const sdk = new DeviceSdkBuilder()
.addLogger(new ConsoleLogger())
.addLogger(new ConsoleLogger(LogLevel.Info))
.build();

const SdkContext = createContext<DeviceSdk>(sdk);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ describe("GetDeviceStatusDeviceAction", () => {
sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel,
deviceStatus: DeviceStatus.LOCKED,
currentApp: "mockedCurrentApp",
installedApps: [],
});

apiGetDeviceSessionStateObservableMock.mockImplementation(
Expand All @@ -120,7 +119,6 @@ describe("GetDeviceStatusDeviceAction", () => {
DeviceSessionStateType.ReadyWithoutSecureChannel,
deviceStatus: DeviceStatus.CONNECTED,
currentApp: "mockedCurrentApp",
installedApps: [],
});
o.complete();
} else {
Expand All @@ -129,7 +127,6 @@ describe("GetDeviceStatusDeviceAction", () => {
DeviceSessionStateType.ReadyWithoutSecureChannel,
deviceStatus: DeviceStatus.LOCKED,
currentApp: "mockedCurrentApp",
installedApps: [],
});
}
},
Expand Down Expand Up @@ -382,7 +379,6 @@ describe("GetDeviceStatusDeviceAction", () => {
DeviceSessionStateType.ReadyWithoutSecureChannel,
deviceStatus: DeviceStatus.LOCKED,
currentApp: "mockedCurrentApp",
installedApps: [],
});
},
});
Expand Down Expand Up @@ -647,7 +643,6 @@ describe("GetDeviceStatusDeviceAction", () => {
sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel,
deviceStatus: DeviceStatus.CONNECTED,
currentApp: "mockedCurrentApp",
installedApps: [],
});

sendCommandMock.mockResolvedValue(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ describe("GoToDashboardDeviceAction", () => {
sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel,
deviceStatus: DeviceStatus.CONNECTED,
currentApp: "BOLOS",
installedApps: [],
});

const expectedStates: Array<GoToDashboardDAState> = [
Expand Down Expand Up @@ -104,7 +103,6 @@ describe("GoToDashboardDeviceAction", () => {
sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel,
deviceStatus: DeviceStatus.CONNECTED,
currentApp: "Bitcoin",
installedApps: [],
});

sendCommandMock
Expand Down Expand Up @@ -292,7 +290,6 @@ describe("GoToDashboardDeviceAction", () => {
sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel,
deviceStatus: DeviceStatus.CONNECTED,
currentApp: "BOLOS",
installedApps: [],
});

const expectedStates: Array<GoToDashboardDAState> = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ describe("OpenAppDeviceAction", () => {
sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel,
deviceStatus: DeviceStatus.CONNECTED,
currentApp: "Bitcoin",
installedApps: [],
});

sendCommandMock.mockResolvedValueOnce(
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/api/device-session/DeviceSessionState.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { BatteryStatusFlags } from "@api/command/os/GetBatteryStatusCommand";
import { DeviceStatus } from "@api/device/DeviceStatus";
import { Application } from "@internal/manager-api/model/ManagerApiType";

/**
* The battery status of a device.
Expand Down Expand Up @@ -77,7 +76,7 @@ type DeviceSessionReadyState = {
/**
* The current applications installed on the device.
*/
readonly installedApps: Application[];
// readonly installedApps: Application[];
};

/**
Expand Down
59 changes: 33 additions & 26 deletions packages/core/src/internal/device-session/model/DeviceSession.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { BehaviorSubject } from "rxjs";
import { v4 as uuidv4 } from "uuid";

import { Command } from "@api/command/Command";
Expand All @@ -17,6 +16,8 @@ import {
} from "@api/device-session/DeviceSessionState";
import { DeviceSessionId } from "@api/device-session/types";
import { SdkError } from "@api/Error";
import { DefaultEventDispatcher } from "@internal/event-dispatcher/service/DefaultEventDispatcher";
import { EventDispatcher } from "@internal/event-dispatcher/service/EventDispatcher";
import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService";
import { type ManagerApiService } from "@internal/manager-api/service/ManagerApiService";
import { InternalConnectedDevice } from "@internal/usb/model/InternalConnectedDevice";
Expand All @@ -34,9 +35,9 @@ export type SessionConstructorArgs = {
export class DeviceSession {
private readonly _id: DeviceSessionId;
private readonly _connectedDevice: InternalConnectedDevice;
private readonly _deviceState: BehaviorSubject<DeviceSessionState>;
private readonly _refresher: DeviceSessionRefresher;
private readonly _managerApiService: ManagerApiService;
private readonly _deviceState: EventDispatcher<DeviceSessionState>;

constructor(
{ connectedDevice, id = uuidv4() }: SessionConstructorArgs,
Expand All @@ -45,26 +46,25 @@ export class DeviceSession {
) {
this._id = id;
this._connectedDevice = connectedDevice;
this._deviceState = new BehaviorSubject<DeviceSessionState>({

this._deviceState = new DefaultEventDispatcher<DeviceSessionState>({
sessionStateType: DeviceSessionStateType.Connected,
deviceStatus: DeviceStatus.CONNECTED,
});

this._refresher = new DeviceSessionRefresher(
{
refreshInterval: 1000,
deviceStatus: DeviceStatus.CONNECTED,
deviceState: this._deviceState,
sendApduFn: (rawApdu: Uint8Array) =>
this.sendApdu(rawApdu, {
isPolling: true,
triggersDisconnection: false,
}),
updateStateFn: (callback) => {
const state = this._deviceState.getValue();
this.setDeviceSessionState(callback(state));
},
},
loggerModuleFactory("device-session-refresher"),
);

this._managerApiService = managerApiService;
}

Expand All @@ -77,20 +77,11 @@ export class DeviceSession {
}

public get state() {
return this._deviceState.asObservable();
return this._deviceState.listen();
}

public setDeviceSessionState(state: DeviceSessionState) {
this._deviceState.next(state);
}

private updateDeviceStatus(deviceStatus: DeviceStatus) {
const sessionState = this._deviceState.getValue();
this._refresher.setDeviceStatus(deviceStatus);
this._deviceState.next({
...sessionState,
deviceStatus,
});
this._deviceState.dispatch(state);
}

async sendApdu(
Expand All @@ -100,7 +91,12 @@ export class DeviceSession {
triggersDisconnection: false,
},
) {
if (!options.isPolling) this.updateDeviceStatus(DeviceStatus.BUSY);
if (!options.isPolling) {
this._deviceState.dispatch({
...this._deviceState.get(),
deviceStatus: DeviceStatus.BUSY,
});
}

const errorOrResponse = await this._connectedDevice.sendApdu(
rawApdu,
Expand All @@ -109,9 +105,15 @@ export class DeviceSession {

return errorOrResponse.ifRight((response) => {
if (CommandUtils.isLockedDeviceResponse(response)) {
this.updateDeviceStatus(DeviceStatus.LOCKED);
this._deviceState.dispatch({
...this._deviceState.get(),
deviceStatus: DeviceStatus.LOCKED,
});
} else {
this.updateDeviceStatus(DeviceStatus.CONNECTED);
this._deviceState.dispatch({
...this._deviceState.get(),
deviceStatus: DeviceStatus.CONNECTED,
});
}
});
}
Expand Down Expand Up @@ -146,11 +148,11 @@ export class DeviceSession {
sendCommand: async <Response, ErrorStatusCodes, Args>(
command: Command<Response, ErrorStatusCodes, Args>,
) => this.sendCommand(command),
getDeviceSessionState: () => this._deviceState.getValue(),
getDeviceSessionState: () => this._deviceState.get(),
getDeviceSessionStateObservable: () => this.state,
setDeviceSessionState: (state: DeviceSessionState) => {
this.setDeviceSessionState(state);
return this._deviceState.getValue();
return this._deviceState.get();
},
getMetadataForAppHashes: (apps: ListAppsResponse) =>
this._managerApiService.getAppsByHash(apps),
Expand All @@ -163,7 +165,12 @@ export class DeviceSession {
}

close() {
this.updateDeviceStatus(DeviceStatus.NOT_CONNECTED);
this._deviceState.complete();
this._deviceState.dispatch({
...this._deviceState.get(),
deviceStatus: DeviceStatus.NOT_CONNECTED,
});

this._refresher.stop();
this._deviceState.close();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,24 @@ import {
} from "@api/command/os/GetAppAndVersionCommand";
import { DeviceStatus } from "@api/device/DeviceStatus";
import { ApduResponse } from "@api/device-session/ApduResponse";
import { DeviceSessionState } from "@api/device-session/DeviceSessionState";
import { DefaultEventDispatcher } from "@internal/event-dispatcher/service/DefaultEventDispatcher";
import { DefaultLoggerPublisherService } from "@internal/logger-publisher/service/DefaultLoggerPublisherService";
import { LoggerPublisherService } from "@internal/logger-publisher/service/LoggerPublisherService";

import { DeviceSessionRefresher } from "./DeviceSessionRefresher";

const mockSendApduFn = jest.fn().mockResolvedValue(Right({} as ApduResponse));
const mockUpdateStateFn = jest.fn().mockImplementation(() => undefined);

jest.mock<DefaultEventDispatcher<DeviceSessionState>>(
"@internal/event-dispatcher/service/DefaultEventDispatcher",
);
jest.useFakeTimers();

const mockSendApduFn = jest.fn().mockResolvedValue(Right({} as ApduResponse));

describe("DeviceSessionRefresher", () => {
let deviceSessionRefresher: DeviceSessionRefresher;
let logger: LoggerPublisherService;
let deviceState: DefaultEventDispatcher<DeviceSessionState>;

beforeEach(() => {
jest
Expand All @@ -31,16 +36,25 @@ describe("DeviceSessionRefresher", () => {
} as GetAppAndVersionResponse,
}),
);

logger = new DefaultLoggerPublisherService(
[],
"DeviceSessionRefresherTest",
);

deviceState = new DefaultEventDispatcher<DeviceSessionState>({
deviceStatus: DeviceStatus.CONNECTED,
} as DeviceSessionState);

jest.spyOn(deviceState, "get").mockReturnValue({
deviceStatus: DeviceStatus.CONNECTED,
} as DeviceSessionState);

deviceSessionRefresher = new DeviceSessionRefresher(
{
refreshInterval: 1000,
deviceStatus: DeviceStatus.CONNECTED,
deviceState,
sendApduFn: mockSendApduFn,
updateStateFn: mockUpdateStateFn,
},
logger,
);
Expand All @@ -60,15 +74,19 @@ describe("DeviceSessionRefresher", () => {
});

it("should not poll when device is busy", () => {
deviceSessionRefresher.setDeviceStatus(DeviceStatus.BUSY);
jest.spyOn(deviceState, "get").mockReturnValue({
deviceStatus: DeviceStatus.BUSY,
} as DeviceSessionState);

jest.advanceTimersByTime(1000);

expect(mockSendApduFn).not.toHaveBeenCalled();
});

it("should not poll when device is disconnected", () => {
deviceSessionRefresher.setDeviceStatus(DeviceStatus.NOT_CONNECTED);
jest.spyOn(deviceState, "get").mockReturnValue({
deviceStatus: DeviceStatus.NOT_CONNECTED,
} as DeviceSessionState);

jest.advanceTimersByTime(1000);

Expand All @@ -79,7 +97,7 @@ describe("DeviceSessionRefresher", () => {
jest.advanceTimersByTime(1000);

expect(await mockSendApduFn()).toEqual(Right({}));
expect(mockUpdateStateFn).toHaveBeenCalled();
expect(deviceState.dispatch).toHaveBeenCalled();
});

it("should not update device session state with failed polling response", async () => {
Expand All @@ -89,16 +107,10 @@ describe("DeviceSessionRefresher", () => {
jest.advanceTimersByTime(1000);
await mockSendApduFn();

expect(mockUpdateStateFn).not.toHaveBeenCalled();
expect(deviceState.dispatch).not.toHaveBeenCalled();
expect(spy).toHaveBeenCalled();
});

it("should stop the refresher when device is disconnected", () => {
const spy = jest.spyOn(deviceSessionRefresher, "stop");
deviceSessionRefresher.setDeviceStatus(DeviceStatus.NOT_CONNECTED);
expect(spy).toHaveBeenCalledTimes(1);
});

it("should not throw error if stop is called on a stopped refresher", () => {
deviceSessionRefresher.stop();
expect(() => deviceSessionRefresher.stop()).not.toThrow();
Expand Down
Loading

0 comments on commit bbbb7c1

Please sign in to comment.