Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ (core) [NO-ISSUE]: Event based device session refresher using rxjs #229

Draft
wants to merge 1 commit into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SHOULD] put back to no arg

.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
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
55 changes: 29 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,11 @@ export class DeviceSession {
triggersDisconnection: false,
},
) {
if (!options.isPolling) this.updateDeviceStatus(DeviceStatus.BUSY);
if (!options.isPolling) {
this._deviceState.dispatch({
deviceStatus: DeviceStatus.BUSY,
});
}

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

return errorOrResponse.ifRight((response) => {
if (CommandUtils.isLockedDeviceResponse(response)) {
this.updateDeviceStatus(DeviceStatus.LOCKED);
this._deviceState.dispatch({
deviceStatus: DeviceStatus.LOCKED,
});
} else {
this.updateDeviceStatus(DeviceStatus.CONNECTED);
this._deviceState.dispatch({
deviceStatus: DeviceStatus.CONNECTED,
});
}
});
}
Expand Down Expand Up @@ -146,11 +145,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 +162,11 @@ export class DeviceSession {
}

close() {
this.updateDeviceStatus(DeviceStatus.NOT_CONNECTED);
this._deviceState.complete();
this._deviceState.dispatch({
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