feat(sdk): add kotlin-platform-sdk (13 components) + 4 new TS client packages (32 tests)
This commit is contained in:
parent
92a6929238
commit
91c48a7bc7
21
packages/feature-flag-client/package.json
Normal file
21
packages/feature-flag-client/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@bytelyst/feature-flag-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Browser/React Native-safe feature flag client for platform-service",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
165
packages/feature-flag-client/src/client.test.ts
Normal file
165
packages/feature-flag-client/src/client.test.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createFeatureFlagClient } from './client.js';
|
||||
|
||||
describe('createFeatureFlagClient', () => {
|
||||
const baseConfig = {
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
productId: 'testapp',
|
||||
platform: 'web',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should create a client with isEnabled returning false before init', () => {
|
||||
const client = createFeatureFlagClient(baseConfig);
|
||||
expect(client.isEnabled('any_flag')).toBe(false);
|
||||
});
|
||||
|
||||
it('should fetch flags on init', async () => {
|
||||
const mockFlags = { premium: true, beta_feature: false };
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ flags: mockFlags }),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createFeatureFlagClient(baseConfig);
|
||||
await client.init();
|
||||
|
||||
expect(client.isEnabled('premium')).toBe(true);
|
||||
expect(client.isEnabled('beta_feature')).toBe(false);
|
||||
expect(client.isEnabled('nonexistent')).toBe(false);
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it('should return all flags via getAllFlags', async () => {
|
||||
const mockFlags = { a: true, b: false };
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ flags: mockFlags }),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createFeatureFlagClient(baseConfig);
|
||||
await client.init();
|
||||
|
||||
expect(client.getAllFlags()).toEqual({ a: true, b: false });
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it('should send correct headers', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ flags: {} }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const client = createFeatureFlagClient(baseConfig);
|
||||
await client.init({ userId: 'user-123' });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/flags/poll?'),
|
||||
expect.objectContaining({
|
||||
headers: { 'x-product-id': 'testapp' },
|
||||
})
|
||||
);
|
||||
|
||||
const url = fetchMock.mock.calls[0][0] as string;
|
||||
expect(url).toContain('platform=web');
|
||||
expect(url).toContain('userId=user-123');
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it('should keep existing flags on network error', async () => {
|
||||
let callCount = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ flags: { initial: true } }),
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error('network'));
|
||||
})
|
||||
);
|
||||
|
||||
const client = createFeatureFlagClient(baseConfig);
|
||||
await client.init();
|
||||
expect(client.isEnabled('initial')).toBe(true);
|
||||
|
||||
await client.refresh();
|
||||
expect(client.isEnabled('initial')).toBe(true);
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it('should persist to storage when provided', async () => {
|
||||
const store: Record<string, string> = {};
|
||||
const storage = {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
store[k] = v;
|
||||
},
|
||||
};
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ flags: { cached: true } }),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createFeatureFlagClient({ ...baseConfig, storage });
|
||||
await client.init();
|
||||
|
||||
expect(store['testapp-feature-flags']).toBeDefined();
|
||||
expect(JSON.parse(store['testapp-feature-flags'])).toEqual({ cached: true });
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it('should restore flags from storage on creation', () => {
|
||||
const store: Record<string, string> = {
|
||||
'testapp-feature-flags': JSON.stringify({ restored: true }),
|
||||
};
|
||||
const storage = {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
store[k] = v;
|
||||
},
|
||||
};
|
||||
|
||||
const client = createFeatureFlagClient({ ...baseConfig, storage });
|
||||
expect(client.isEnabled('restored')).toBe(true);
|
||||
});
|
||||
|
||||
it('should stop polling on stop()', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ flags: {} }),
|
||||
})
|
||||
);
|
||||
|
||||
const client = createFeatureFlagClient({ ...baseConfig, pollIntervalMs: 1000 });
|
||||
await client.init();
|
||||
client.stop();
|
||||
|
||||
expect(client.isEnabled('anything')).toBe(false);
|
||||
expect(client.getAllFlags()).toEqual({});
|
||||
});
|
||||
});
|
||||
110
packages/feature-flag-client/src/client.ts
Normal file
110
packages/feature-flag-client/src/client.ts
Normal file
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Browser/React Native-safe feature flag client for platform-service.
|
||||
*
|
||||
* Polls GET /api/flags/poll on a configurable interval and caches results.
|
||||
* No Node.js dependencies — uses globalThis.fetch.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createFeatureFlagClient } from '@bytelyst/feature-flag-client';
|
||||
*
|
||||
* const flags = createFeatureFlagClient({
|
||||
* baseUrl: 'http://localhost:4003/api',
|
||||
* productId: 'nomgap',
|
||||
* platform: 'mobile',
|
||||
* });
|
||||
*
|
||||
* await flags.init({ userId: 'user-123' });
|
||||
* if (flags.isEnabled('premium_body_viz')) { ... }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { FeatureFlagClient, FeatureFlagClientConfig } from './types.js';
|
||||
|
||||
export function createFeatureFlagClient(config: FeatureFlagClientConfig): FeatureFlagClient {
|
||||
const {
|
||||
baseUrl,
|
||||
productId,
|
||||
platform,
|
||||
pollIntervalMs = 5 * 60 * 1000,
|
||||
storage,
|
||||
storagePrefix,
|
||||
} = config;
|
||||
|
||||
const prefix = storagePrefix ?? productId;
|
||||
const STORAGE_KEY = `${prefix}-feature-flags`;
|
||||
|
||||
let flags: Record<string, boolean> = {};
|
||||
let initialized = false;
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
let userId: string | undefined;
|
||||
|
||||
// Restore from storage on creation
|
||||
if (storage) {
|
||||
try {
|
||||
const cached = storage.getItem(STORAGE_KEY);
|
||||
if (cached) flags = JSON.parse(cached);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFlags(): Promise<void> {
|
||||
try {
|
||||
const parts = [`platform=${encodeURIComponent(platform)}`];
|
||||
if (userId) parts.push(`userId=${encodeURIComponent(userId)}`);
|
||||
|
||||
const res = await globalThis.fetch(`${baseUrl}/flags/poll?${parts.join('&')}`, {
|
||||
headers: { 'x-product-id': productId },
|
||||
});
|
||||
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = (await res.json()) as { flags?: Record<string, boolean> };
|
||||
flags = data.flags ?? {};
|
||||
|
||||
// Persist to storage
|
||||
if (storage) {
|
||||
try {
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(flags));
|
||||
} catch {
|
||||
// Storage write failure — non-fatal
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep existing flags on network error
|
||||
}
|
||||
}
|
||||
|
||||
async function init(params?: { userId?: string }): Promise<void> {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
userId = params?.userId;
|
||||
await fetchFlags();
|
||||
intervalId = setInterval(() => {
|
||||
void fetchFlags();
|
||||
}, pollIntervalMs);
|
||||
}
|
||||
|
||||
function isEnabled(key: string): boolean {
|
||||
return flags[key] === true;
|
||||
}
|
||||
|
||||
function getAllFlags(): Readonly<Record<string, boolean>> {
|
||||
return flags;
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
await fetchFlags();
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
flags = {};
|
||||
initialized = false;
|
||||
userId = undefined;
|
||||
}
|
||||
|
||||
return { init, isEnabled, getAllFlags, refresh, stop };
|
||||
}
|
||||
2
packages/feature-flag-client/src/index.ts
Normal file
2
packages/feature-flag-client/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { createFeatureFlagClient } from './client.js';
|
||||
export type { FeatureFlagClient, FeatureFlagClientConfig } from './types.js';
|
||||
44
packages/feature-flag-client/src/types.ts
Normal file
44
packages/feature-flag-client/src/types.ts
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Types for @bytelyst/feature-flag-client.
|
||||
* Browser/React Native-safe — no Node.js dependencies.
|
||||
*/
|
||||
|
||||
export interface FeatureFlagClientConfig {
|
||||
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
|
||||
baseUrl: string;
|
||||
|
||||
/** Product identifier sent as x-product-id header. */
|
||||
productId: string;
|
||||
|
||||
/** Platform string for the poll query (e.g. "mobile", "web"). */
|
||||
platform: string;
|
||||
|
||||
/** Poll interval in milliseconds. Default: 5 minutes. */
|
||||
pollIntervalMs?: number;
|
||||
|
||||
/** Optional persistent storage adapter for flag cache. */
|
||||
storage?: {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
};
|
||||
|
||||
/** Optional storage key prefix. Default: productId. */
|
||||
storagePrefix?: string;
|
||||
}
|
||||
|
||||
export interface FeatureFlagClient {
|
||||
/** Initialize the client: fetch flags immediately and start polling. */
|
||||
init(params?: { userId?: string }): Promise<void>;
|
||||
|
||||
/** Check if a feature flag is enabled. Returns false if not found. */
|
||||
isEnabled(key: string): boolean;
|
||||
|
||||
/** Get all currently cached flags. */
|
||||
getAllFlags(): Readonly<Record<string, boolean>>;
|
||||
|
||||
/** Force a refresh of feature flags. */
|
||||
refresh(): Promise<void>;
|
||||
|
||||
/** Stop polling and reset state. */
|
||||
stop(): void;
|
||||
}
|
||||
8
packages/feature-flag-client/tsconfig.json
Normal file
8
packages/feature-flag-client/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
21
packages/kill-switch-client/package.json
Normal file
21
packages/kill-switch-client/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@bytelyst/kill-switch-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Browser/React Native-safe kill switch client for platform-service",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
102
packages/kill-switch-client/src/index.test.ts
Normal file
102
packages/kill-switch-client/src/index.test.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { createKillSwitchClient } from './index.js';
|
||||
|
||||
describe('createKillSwitchClient', () => {
|
||||
const baseConfig = {
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
productId: 'testapp',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return disabled=false when app is not disabled', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ disabled: false, message: null }),
|
||||
})
|
||||
);
|
||||
|
||||
const ks = createKillSwitchClient(baseConfig);
|
||||
const result = await ks.check();
|
||||
|
||||
expect(result.disabled).toBe(false);
|
||||
expect(result.message).toBeNull();
|
||||
});
|
||||
|
||||
it('should return disabled=true with message when app is disabled', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ disabled: true, message: 'Maintenance in progress' }),
|
||||
})
|
||||
);
|
||||
|
||||
const ks = createKillSwitchClient(baseConfig);
|
||||
const result = await ks.check();
|
||||
|
||||
expect(result.disabled).toBe(true);
|
||||
expect(result.message).toBe('Maintenance in progress');
|
||||
});
|
||||
|
||||
it('should fail-open on network error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')));
|
||||
|
||||
const ks = createKillSwitchClient(baseConfig);
|
||||
const result = await ks.check();
|
||||
|
||||
expect(result.disabled).toBe(false);
|
||||
expect(result.message).toBeNull();
|
||||
});
|
||||
|
||||
it('should fail-open on non-OK response', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
})
|
||||
);
|
||||
|
||||
const ks = createKillSwitchClient(baseConfig);
|
||||
const result = await ks.check();
|
||||
|
||||
expect(result.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should send correct product-id header', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ disabled: false }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const ks = createKillSwitchClient(baseConfig);
|
||||
await ks.check();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/flags/kill-switch'),
|
||||
expect.objectContaining({
|
||||
headers: { 'x-product-id': 'testapp' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include platform in query string', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ disabled: false }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const ks = createKillSwitchClient({ ...baseConfig, platform: 'ios' });
|
||||
await ks.check();
|
||||
|
||||
const url = fetchMock.mock.calls[0][0] as string;
|
||||
expect(url).toContain('platform=ios');
|
||||
});
|
||||
});
|
||||
68
packages/kill-switch-client/src/index.ts
Normal file
68
packages/kill-switch-client/src/index.ts
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Browser/React Native-safe kill switch client for platform-service.
|
||||
*
|
||||
* Checks GET /api/flags/kill-switch to determine if the app is disabled.
|
||||
* Fail-open: returns { disabled: false } on any network error.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createKillSwitchClient } from '@bytelyst/kill-switch-client';
|
||||
*
|
||||
* const ks = createKillSwitchClient({
|
||||
* baseUrl: 'http://localhost:4003/api',
|
||||
* productId: 'nomgap',
|
||||
* });
|
||||
*
|
||||
* const result = await ks.check();
|
||||
* if (result.disabled) showBlockScreen(result.message);
|
||||
* ```
|
||||
*/
|
||||
|
||||
export interface KillSwitchClientConfig {
|
||||
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
|
||||
baseUrl: string;
|
||||
|
||||
/** Product identifier sent as x-product-id header. */
|
||||
productId: string;
|
||||
|
||||
/** Platform string for the query (e.g. "mobile", "web"). Default: "mobile". */
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
export interface KillSwitchResult {
|
||||
disabled: boolean;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface KillSwitchClient {
|
||||
/** Check if the app is disabled. Fail-open on any error. */
|
||||
check(): Promise<KillSwitchResult>;
|
||||
}
|
||||
|
||||
export function createKillSwitchClient(config: KillSwitchClientConfig): KillSwitchClient {
|
||||
const { baseUrl, productId, platform = 'mobile' } = config;
|
||||
|
||||
async function check(): Promise<KillSwitchResult> {
|
||||
try {
|
||||
const res = await globalThis.fetch(
|
||||
`${baseUrl}/flags/kill-switch?platform=${encodeURIComponent(platform)}`,
|
||||
{
|
||||
headers: { 'x-product-id': productId },
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return { disabled: false, message: null };
|
||||
|
||||
const data = (await res.json()) as KillSwitchResult;
|
||||
return {
|
||||
disabled: data.disabled ?? false,
|
||||
message: data.message ?? null,
|
||||
};
|
||||
} catch {
|
||||
// Fail-open: network errors should NOT block the user
|
||||
return { disabled: false, message: null };
|
||||
}
|
||||
}
|
||||
|
||||
return { check };
|
||||
}
|
||||
8
packages/kill-switch-client/tsconfig.json
Normal file
8
packages/kill-switch-client/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
57
packages/kotlin-platform-sdk/build.gradle.kts
Normal file
57
packages/kotlin-platform-sdk/build.gradle.kts
Normal file
@ -0,0 +1,57 @@
|
||||
plugins {
|
||||
id("com.android.library") version "8.7.3"
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.bytelyst.platform"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
group = "com.bytelyst.platform"
|
||||
version = "0.1.0"
|
||||
|
||||
dependencies {
|
||||
// HTTP
|
||||
api("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
// Serialization
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
|
||||
// Android
|
||||
implementation("androidx.security:security-crypto:1.0.0")
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
|
||||
// Testing
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-api:5.11.4")
|
||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.11.4")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||
testImplementation("org.robolectric:robolectric:4.14.1")
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
3
packages/kotlin-platform-sdk/consumer-rules.pro
Normal file
3
packages/kotlin-platform-sdk/consumer-rules.pro
Normal file
@ -0,0 +1,3 @@
|
||||
# ByteLyst Platform SDK — consumer ProGuard rules
|
||||
# Keep all SDK public API classes
|
||||
-keep class com.bytelyst.platform.** { *; }
|
||||
1
packages/kotlin-platform-sdk/gradle.properties
Normal file
1
packages/kotlin-platform-sdk/gradle.properties
Normal file
@ -0,0 +1 @@
|
||||
android.useAndroidX=true
|
||||
1
packages/kotlin-platform-sdk/settings.gradle.kts
Normal file
1
packages/kotlin-platform-sdk/settings.gradle.kts
Normal file
@ -0,0 +1 @@
|
||||
rootProject.name = "kotlin-platform-sdk"
|
||||
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- No permissions needed at the library level.
|
||||
Consumer apps declare their own permissions. -->
|
||||
</manifest>
|
||||
@ -0,0 +1,111 @@
|
||||
package com.bytelyst.platform
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* Local rotating JSON audit log.
|
||||
*
|
||||
* Writes audit entries to a JSON lines file in the app's files directory.
|
||||
* Rotates when the file exceeds [maxFileSizeBytes]. Keeps [maxFiles] rotated files.
|
||||
*
|
||||
* Mirrors the Swift BLAuditLogger API.
|
||||
*/
|
||||
class BLAuditLogger(
|
||||
context: Context,
|
||||
private val config: BLPlatformConfig,
|
||||
private val maxFileSizeBytes: Long = 1_000_000L,
|
||||
private val maxFiles: Int = 5,
|
||||
) {
|
||||
@Serializable
|
||||
data class AuditEntry(
|
||||
val timestamp: String,
|
||||
val productId: String,
|
||||
val action: String,
|
||||
val module: String,
|
||||
val detail: String? = null,
|
||||
val userId: String? = null,
|
||||
)
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
private val logDir = File(context.filesDir, "audit_logs")
|
||||
private val currentFile: File
|
||||
get() = File(logDir, "${config.productId}_audit.jsonl")
|
||||
|
||||
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
init {
|
||||
logDir.mkdirs()
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an audit event.
|
||||
*/
|
||||
fun log(action: String, module: String, detail: String? = null, userId: String? = null) {
|
||||
val entry = AuditEntry(
|
||||
timestamp = isoFormat.format(Date()),
|
||||
productId = config.productId,
|
||||
action = action,
|
||||
module = module,
|
||||
detail = detail,
|
||||
userId = userId,
|
||||
)
|
||||
|
||||
try {
|
||||
rotateIfNeeded()
|
||||
currentFile.appendText(json.encodeToString(entry) + "\n")
|
||||
} catch (_: Exception) {
|
||||
// Audit logging should never crash the app
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all entries from the current log file.
|
||||
*/
|
||||
fun readEntries(): List<AuditEntry> {
|
||||
return try {
|
||||
if (!currentFile.exists()) return emptyList()
|
||||
currentFile.readLines()
|
||||
.filter { it.isNotBlank() }
|
||||
.mapNotNull {
|
||||
try { json.decodeFromString<AuditEntry>(it) } catch (_: Exception) { null }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all audit log files.
|
||||
*/
|
||||
fun clear() {
|
||||
try {
|
||||
logDir.listFiles()?.forEach { it.delete() }
|
||||
} catch (_: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
private fun rotateIfNeeded() {
|
||||
if (!currentFile.exists() || currentFile.length() < maxFileSizeBytes) return
|
||||
|
||||
// Rotate: current → .1, .1 → .2, etc.
|
||||
for (i in maxFiles downTo 1) {
|
||||
val from = if (i == 1) currentFile else File(logDir, "${config.productId}_audit.${i - 1}.jsonl")
|
||||
val to = File(logDir, "${config.productId}_audit.$i.jsonl")
|
||||
if (from.exists()) {
|
||||
to.delete()
|
||||
from.renameTo(to)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,304 @@
|
||||
package com.bytelyst.platform
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Auth client for platform-service.
|
||||
*
|
||||
* Manages login, register, token refresh, password operations.
|
||||
* Tokens are stored in [BLSecureStore] (EncryptedSharedPreferences).
|
||||
* Auth state is exposed as a [StateFlow] for reactive UI binding.
|
||||
*
|
||||
* Mirrors the Swift BLAuthClient API.
|
||||
*/
|
||||
class BLAuthClient(
|
||||
private val config: BLPlatformConfig,
|
||||
private val secureStore: BLSecureStore,
|
||||
) {
|
||||
// ── Data classes ──────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class AuthUser(
|
||||
val id: String = "",
|
||||
val email: String = "",
|
||||
@SerialName("displayName")
|
||||
val name: String = "",
|
||||
val plan: String = "free",
|
||||
val role: String = "user",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TokenResponse(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val user: AuthUser,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RefreshResponse(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessageResponse(val message: String)
|
||||
|
||||
sealed class AuthState {
|
||||
data object Loading : AuthState()
|
||||
data object LoggedOut : AuthState()
|
||||
data class LoggedIn(val user: AuthUser) : AuthState()
|
||||
data class Error(val message: String) : AuthState()
|
||||
}
|
||||
|
||||
// ── State ────────────────────────────────────────────────
|
||||
|
||||
private val _state = MutableStateFlow<AuthState>(AuthState.Loading)
|
||||
val state: StateFlow<AuthState> = _state.asStateFlow()
|
||||
|
||||
val isLoggedIn: Boolean
|
||||
get() = _state.value is AuthState.LoggedIn
|
||||
|
||||
val currentUser: AuthUser?
|
||||
get() = (_state.value as? AuthState.LoggedIn)?.user
|
||||
|
||||
// ── Storage keys (bare — applicationId provides namespace) ──
|
||||
|
||||
companion object {
|
||||
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||
private const val KEY_USER_EMAIL = "user_email"
|
||||
private const val KEY_USER_NAME = "user_name"
|
||||
private const val KEY_USER_PLAN = "user_plan"
|
||||
private const val KEY_USER_ID = "user_id"
|
||||
}
|
||||
|
||||
// ── Platform client ──────────────────────────────────────
|
||||
|
||||
private val client = BLPlatformClient(config) { getAccessToken() }
|
||||
|
||||
// ── Token management ─────────────────────────────────────
|
||||
|
||||
fun getAccessToken(): String? = secureStore.read(KEY_ACCESS_TOKEN)
|
||||
|
||||
fun getRefreshToken(): String? = secureStore.read(KEY_REFRESH_TOKEN)
|
||||
|
||||
private fun setTokens(accessToken: String, refreshToken: String) {
|
||||
secureStore.save(KEY_ACCESS_TOKEN, accessToken)
|
||||
secureStore.save(KEY_REFRESH_TOKEN, refreshToken)
|
||||
}
|
||||
|
||||
private fun saveUser(user: AuthUser) {
|
||||
secureStore.save(KEY_USER_ID, user.id)
|
||||
secureStore.save(KEY_USER_EMAIL, user.email)
|
||||
secureStore.save(KEY_USER_NAME, user.name)
|
||||
secureStore.save(KEY_USER_PLAN, user.plan)
|
||||
}
|
||||
|
||||
private fun clearAll() {
|
||||
secureStore.delete(KEY_ACCESS_TOKEN)
|
||||
secureStore.delete(KEY_REFRESH_TOKEN)
|
||||
secureStore.delete(KEY_USER_EMAIL)
|
||||
secureStore.delete(KEY_USER_NAME)
|
||||
secureStore.delete(KEY_USER_PLAN)
|
||||
secureStore.delete(KEY_USER_ID)
|
||||
}
|
||||
|
||||
// ── Session check ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check for an existing session from stored tokens.
|
||||
* Call once at app startup to restore auth state.
|
||||
*/
|
||||
fun checkExistingSession() {
|
||||
val token = secureStore.read(KEY_ACCESS_TOKEN)
|
||||
val email = secureStore.read(KEY_USER_EMAIL)
|
||||
if (!token.isNullOrBlank() && !email.isNullOrBlank()) {
|
||||
val user = AuthUser(
|
||||
id = secureStore.read(KEY_USER_ID) ?: "",
|
||||
email = email,
|
||||
name = secureStore.read(KEY_USER_NAME) ?: "",
|
||||
plan = secureStore.read(KEY_USER_PLAN) ?: "free",
|
||||
)
|
||||
_state.value = AuthState.LoggedIn(user)
|
||||
} else {
|
||||
_state.value = AuthState.LoggedOut
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth operations ──────────────────────────────────────
|
||||
|
||||
suspend fun login(email: String, password: String) {
|
||||
_state.value = AuthState.Loading
|
||||
try {
|
||||
val body = client.json.encodeToString(
|
||||
kotlinx.serialization.builtins.MapSerializer(
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
),
|
||||
mapOf("email" to email, "password" to password, "productId" to config.productId),
|
||||
)
|
||||
val response = client.request("POST", "/auth/login", body, skipAuth = true)
|
||||
val result = client.json.decodeFromString<TokenResponse>(response)
|
||||
handleAuthResult(result)
|
||||
} catch (e: Exception) {
|
||||
_state.value = AuthState.Error(e.message ?: "Login failed")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun register(name: String, email: String, password: String) {
|
||||
_state.value = AuthState.Loading
|
||||
try {
|
||||
val body = client.json.encodeToString(
|
||||
kotlinx.serialization.builtins.MapSerializer(
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
),
|
||||
mapOf(
|
||||
"email" to email,
|
||||
"displayName" to name,
|
||||
"password" to password,
|
||||
"productId" to config.productId,
|
||||
),
|
||||
)
|
||||
val response = client.request("POST", "/auth/register", body, skipAuth = true)
|
||||
val result = client.json.decodeFromString<TokenResponse>(response)
|
||||
handleAuthResult(result)
|
||||
} catch (e: Exception) {
|
||||
_state.value = AuthState.Error(e.message ?: "Registration failed")
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
clearAll()
|
||||
_state.value = AuthState.LoggedOut
|
||||
}
|
||||
|
||||
// ── Token refresh (singleton guard) ──────────────────────
|
||||
|
||||
private val refreshMutex = Mutex()
|
||||
|
||||
suspend fun refreshAccessToken(): Boolean = refreshMutex.withLock {
|
||||
val rt = getRefreshToken() ?: return false
|
||||
return try {
|
||||
val body = client.json.encodeToString(
|
||||
kotlinx.serialization.builtins.MapSerializer(
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
),
|
||||
mapOf("refreshToken" to rt),
|
||||
)
|
||||
val response = client.request("POST", "/auth/refresh", body, skipAuth = true)
|
||||
val result = client.json.decodeFromString<RefreshResponse>(response)
|
||||
setTokens(result.accessToken, result.refreshToken)
|
||||
true
|
||||
} catch (e: BLApiException) {
|
||||
if (e.statusCode == 401) {
|
||||
withContext(Dispatchers.Main) { logout() }
|
||||
}
|
||||
false
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Password management ──────────────────────────────────
|
||||
|
||||
suspend fun forgotPassword(email: String): String {
|
||||
val body = client.json.encodeToString(
|
||||
kotlinx.serialization.builtins.MapSerializer(
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
),
|
||||
mapOf("email" to email, "productId" to config.productId),
|
||||
)
|
||||
val response = client.request("POST", "/auth/forgot-password", body, skipAuth = true)
|
||||
return client.json.decodeFromString<MessageResponse>(response).message
|
||||
}
|
||||
|
||||
suspend fun resetPassword(token: String, newPassword: String): String {
|
||||
val body = client.json.encodeToString(
|
||||
kotlinx.serialization.builtins.MapSerializer(
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
),
|
||||
mapOf("token" to token, "newPassword" to newPassword),
|
||||
)
|
||||
val response = client.request("POST", "/auth/reset-password", body, skipAuth = true)
|
||||
return client.json.decodeFromString<MessageResponse>(response).message
|
||||
}
|
||||
|
||||
suspend fun changePassword(currentPassword: String, newPassword: String): String {
|
||||
val body = client.json.encodeToString(
|
||||
kotlinx.serialization.builtins.MapSerializer(
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
),
|
||||
mapOf("currentPassword" to currentPassword, "newPassword" to newPassword),
|
||||
)
|
||||
val response = client.request("POST", "/auth/change-password", body)
|
||||
return client.json.decodeFromString<MessageResponse>(response).message
|
||||
}
|
||||
|
||||
// ── Email verification ───────────────────────────────────
|
||||
|
||||
suspend fun verifyEmail(token: String): String {
|
||||
val body = client.json.encodeToString(
|
||||
kotlinx.serialization.builtins.MapSerializer(
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
),
|
||||
mapOf("token" to token),
|
||||
)
|
||||
val response = client.request("POST", "/auth/verify-email", body)
|
||||
return client.json.decodeFromString<MessageResponse>(response).message
|
||||
}
|
||||
|
||||
suspend fun resendVerification(email: String): String {
|
||||
val body = client.json.encodeToString(
|
||||
kotlinx.serialization.builtins.MapSerializer(
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
),
|
||||
mapOf("email" to email, "productId" to config.productId),
|
||||
)
|
||||
val response = client.request("POST", "/auth/resend-verification", body)
|
||||
return client.json.decodeFromString<MessageResponse>(response).message
|
||||
}
|
||||
|
||||
// ── Account management ───────────────────────────────────
|
||||
|
||||
suspend fun deleteAccount(password: String): String {
|
||||
val body = client.json.encodeToString(
|
||||
kotlinx.serialization.builtins.MapSerializer(
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
),
|
||||
mapOf("password" to password),
|
||||
)
|
||||
val response = client.request("DELETE", "/auth/account", body)
|
||||
clearAll()
|
||||
_state.value = AuthState.LoggedOut
|
||||
return client.json.decodeFromString<MessageResponse>(response).message
|
||||
}
|
||||
|
||||
suspend fun getMe(): AuthUser {
|
||||
val response = client.request("GET", "/auth/me")
|
||||
return client.json.decodeFromString<AuthUser>(response)
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────
|
||||
|
||||
private fun handleAuthResult(result: TokenResponse) {
|
||||
setTokens(result.accessToken, result.refreshToken)
|
||||
saveUser(result.user)
|
||||
_state.value = AuthState.LoggedIn(result.user)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
package com.bytelyst.platform
|
||||
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
/**
|
||||
* Biometric authentication wrapper (Face / Fingerprint).
|
||||
*
|
||||
* Uses AndroidX BiometricPrompt for hardware-backed authentication.
|
||||
* Mirrors the Swift BLBiometricAuth API.
|
||||
*/
|
||||
object BLBiometricAuth {
|
||||
|
||||
enum class BiometricResult {
|
||||
SUCCESS,
|
||||
CANCELLED,
|
||||
NOT_AVAILABLE,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if biometric authentication is available on this device.
|
||||
*/
|
||||
fun isAvailable(activity: FragmentActivity): Boolean {
|
||||
val manager = BiometricManager.from(activity)
|
||||
return manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
|
||||
BiometricManager.BIOMETRIC_SUCCESS
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a biometric prompt and return the result.
|
||||
*
|
||||
* Must be called from a FragmentActivity (Compose activities extend this).
|
||||
*/
|
||||
suspend fun authenticate(
|
||||
activity: FragmentActivity,
|
||||
title: String = "Authenticate",
|
||||
subtitle: String? = null,
|
||||
negativeButtonText: String = "Cancel",
|
||||
): BiometricResult {
|
||||
if (!isAvailable(activity)) return BiometricResult.NOT_AVAILABLE
|
||||
|
||||
return suspendCoroutine { continuation ->
|
||||
val executor = ContextCompat.getMainExecutor(activity)
|
||||
|
||||
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
continuation.resume(BiometricResult.SUCCESS)
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
val result = if (
|
||||
errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
|
||||
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
|
||||
errorCode == BiometricPrompt.ERROR_CANCELED
|
||||
) {
|
||||
BiometricResult.CANCELLED
|
||||
} else {
|
||||
BiometricResult.ERROR
|
||||
}
|
||||
continuation.resume(result)
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
// Individual attempt failed, prompt stays open — don't resume yet
|
||||
}
|
||||
}
|
||||
|
||||
val prompt = BiometricPrompt(activity, executor, callback)
|
||||
val info = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(title)
|
||||
.apply { subtitle?.let { setSubtitle(it) } }
|
||||
.setNegativeButtonText(negativeButtonText)
|
||||
.build()
|
||||
|
||||
prompt.authenticate(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
package com.bytelyst.platform
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Azure Blob Storage client via platform-service SAS tokens.
|
||||
*
|
||||
* Flow: Get SAS token from POST /api/blob/sas → Upload directly to Azure Blob.
|
||||
* Mirrors the Swift BLBlobClient API.
|
||||
*/
|
||||
class BLBlobClient(
|
||||
private val config: BLPlatformConfig,
|
||||
private val tokenProvider: () -> String? = { null },
|
||||
) {
|
||||
@Serializable
|
||||
data class SasResponse(
|
||||
val sasUrl: String,
|
||||
val blobUrl: String,
|
||||
)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val platformClient = BLPlatformClient(config, tokenProvider)
|
||||
|
||||
private val uploadClient = OkHttpClient.Builder()
|
||||
.connectTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(120, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Upload data to Azure Blob Storage.
|
||||
*
|
||||
* @param data The raw bytes to upload.
|
||||
* @param container Azure Blob container name (e.g., "audio", "attachments").
|
||||
* @param fileName The blob name / file path.
|
||||
* @param contentType MIME type (e.g., "audio/wav", "image/png").
|
||||
* @return The public blob URL on success, or null on failure.
|
||||
*/
|
||||
suspend fun upload(
|
||||
data: ByteArray,
|
||||
container: String,
|
||||
fileName: String,
|
||||
contentType: String,
|
||||
): String? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Step 1: Get SAS token from platform-service
|
||||
val sasBody = json.encodeToString(
|
||||
kotlinx.serialization.builtins.MapSerializer(
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
),
|
||||
mapOf(
|
||||
"container" to container,
|
||||
"blobName" to fileName,
|
||||
"permissions" to "w",
|
||||
"contentType" to contentType,
|
||||
),
|
||||
)
|
||||
val sasResponse = platformClient.request("POST", "/blob/sas", sasBody)
|
||||
val sas = json.decodeFromString<SasResponse>(sasResponse)
|
||||
|
||||
// Step 2: Upload directly to Azure Blob via SAS URL
|
||||
val request = Request.Builder()
|
||||
.url(sas.sasUrl)
|
||||
.put(data.toRequestBody(contentType.toMediaType()))
|
||||
.header("x-ms-blob-type", "BlockBlob")
|
||||
.header("x-ms-blob-content-type", contentType)
|
||||
.build()
|
||||
|
||||
val response = uploadClient.newCall(request).execute()
|
||||
if (response.isSuccessful) sas.blobUrl else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload audio data (convenience method).
|
||||
*/
|
||||
suspend fun uploadAudio(data: ByteArray, fileName: String): String? {
|
||||
return upload(data, "audio", fileName, "audio/wav")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
package com.bytelyst.platform
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Crash reporter — captures uncaught exceptions and reports to telemetry.
|
||||
*
|
||||
* Installs a [Thread.UncaughtExceptionHandler] that:
|
||||
* 1. Saves crash info to SharedPreferences
|
||||
* 2. On next app launch, sends the crash report to platform-service telemetry
|
||||
* 3. Delegates to the previous handler (so the system can still show ANR/crash dialogs)
|
||||
*
|
||||
* Mirrors the Swift BLCrashReporter API (MetricKit equivalent for Android).
|
||||
*/
|
||||
class BLCrashReporter(
|
||||
context: Context,
|
||||
private val config: BLPlatformConfig,
|
||||
) {
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("${config.productId}_crash_reporter", Context.MODE_PRIVATE)
|
||||
private val client = BLPlatformClient(config)
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_PENDING_CRASH = "pending_crash"
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the crash handler and send any pending crash reports.
|
||||
* Call once from Application.onCreate().
|
||||
*/
|
||||
fun install() {
|
||||
sendPendingCrashReport()
|
||||
installHandler()
|
||||
}
|
||||
|
||||
private fun installHandler() {
|
||||
val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
val sw = StringWriter()
|
||||
throwable.printStackTrace(PrintWriter(sw))
|
||||
|
||||
val crashData = buildJsonObject {
|
||||
put("id", UUID.randomUUID().toString())
|
||||
put("productId", config.productId)
|
||||
put("platform", config.platform)
|
||||
put("timestamp", isoFormat.format(Date()))
|
||||
put("thread", thread.name)
|
||||
put("exception", throwable.javaClass.name)
|
||||
put("message", throwable.message ?: "")
|
||||
put("stackTrace", sw.toString().take(4096))
|
||||
}
|
||||
|
||||
// Persist synchronously (we may be about to die)
|
||||
prefs.edit().putString(KEY_PENDING_CRASH, json.encodeToString(crashData)).commit()
|
||||
|
||||
// Delegate to previous handler
|
||||
previousHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendPendingCrashReport() {
|
||||
val pending = prefs.getString(KEY_PENDING_CRASH, null) ?: return
|
||||
prefs.edit().remove(KEY_PENDING_CRASH).apply()
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val body = """{"productId":"${config.productId}","events":[$pending]}"""
|
||||
client.fireAndForget("POST", "/telemetry/events", body)
|
||||
} catch (_: Exception) {
|
||||
// Best-effort — don't re-queue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
package com.bytelyst.platform
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Feature flag client for platform-service.
|
||||
*
|
||||
* Polls GET /api/flags/poll on a configurable interval and caches results
|
||||
* in memory. Consumers call [isEnabled] with a flag key.
|
||||
*
|
||||
* Mirrors the Swift BLFeatureFlagClient API.
|
||||
*/
|
||||
class BLFeatureFlagClient(
|
||||
private val config: BLPlatformConfig,
|
||||
private val pollIntervalMs: Long = 5 * 60 * 1000L,
|
||||
) {
|
||||
@Serializable
|
||||
private data class FlagResponse(val flags: Map<String, Boolean> = emptyMap())
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val client = BLPlatformClient(config)
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var pollJob: Job? = null
|
||||
|
||||
@Volatile
|
||||
private var flags: Map<String, Boolean> = emptyMap()
|
||||
|
||||
private var userId: String? = null
|
||||
|
||||
// ── Public API ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize and start polling. Call once at app startup.
|
||||
*/
|
||||
fun init(userId: String? = null) {
|
||||
this.userId = userId
|
||||
scope.launch { fetchFlags() }
|
||||
startPolling()
|
||||
}
|
||||
|
||||
fun isEnabled(key: String): Boolean = flags[key] == true
|
||||
|
||||
fun getAllFlags(): Map<String, Boolean> = flags.toMap()
|
||||
|
||||
/**
|
||||
* Force a refresh of feature flags.
|
||||
*/
|
||||
suspend fun refresh() {
|
||||
fetchFlags()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
pollJob?.cancel()
|
||||
pollJob = null
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────
|
||||
|
||||
private fun startPolling() {
|
||||
if (pollJob != null) return
|
||||
pollJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(pollIntervalMs)
|
||||
fetchFlags()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchFlags() {
|
||||
try {
|
||||
val qs = buildString {
|
||||
append("?platform=${config.platform}")
|
||||
userId?.let { append("&userId=$it") }
|
||||
}
|
||||
val response = client.request("GET", "/flags/poll$qs", skipAuth = true)
|
||||
val result = json.decodeFromString<FlagResponse>(response)
|
||||
flags = result.flags
|
||||
} catch (_: Exception) {
|
||||
// Keep existing flags on failure
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
package com.bytelyst.platform
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Kill switch client for platform-service.
|
||||
*
|
||||
* Checks GET /api/flags/kill-switch to determine if the app should be disabled.
|
||||
* Fail-open: returns [KillSwitchResult.ok] on any error.
|
||||
*
|
||||
* Mirrors the Swift BLKillSwitchClient API.
|
||||
*/
|
||||
class BLKillSwitchClient(
|
||||
private val config: BLPlatformConfig,
|
||||
) {
|
||||
@Serializable
|
||||
data class KillSwitchResult(
|
||||
val disabled: Boolean = false,
|
||||
val message: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun ok() = KillSwitchResult(disabled = false, message = null)
|
||||
}
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val client = BLPlatformClient(config)
|
||||
|
||||
/**
|
||||
* Check if the app is disabled. Fail-open on any error.
|
||||
*/
|
||||
suspend fun check(): KillSwitchResult {
|
||||
return try {
|
||||
val response = client.request("GET", "/flags/kill-switch?platform=${config.platform}", skipAuth = true)
|
||||
json.decodeFromString<KillSwitchResult>(response)
|
||||
} catch (_: Exception) {
|
||||
KillSwitchResult.ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
package com.bytelyst.platform
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* License client for platform-service.
|
||||
*
|
||||
* Activates and checks license keys via /api/licenses endpoints.
|
||||
* URL-encodes the license key in the path to handle special characters.
|
||||
*
|
||||
* Mirrors the Swift BLLicenseClient API.
|
||||
*/
|
||||
class BLLicenseClient(
|
||||
private val config: BLPlatformConfig,
|
||||
private val tokenProvider: () -> String? = { null },
|
||||
) {
|
||||
@Serializable
|
||||
data class LicenseStatus(
|
||||
val valid: Boolean = false,
|
||||
val plan: String = "free",
|
||||
val expiresAt: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ActivationResult(
|
||||
val success: Boolean = false,
|
||||
val plan: String = "free",
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val client = BLPlatformClient(config, tokenProvider)
|
||||
|
||||
/**
|
||||
* Activate a license key.
|
||||
*/
|
||||
suspend fun activate(licenseKey: String): ActivationResult {
|
||||
return try {
|
||||
val encoded = URLEncoder.encode(licenseKey, "UTF-8")
|
||||
val body = json.encodeToString(
|
||||
kotlinx.serialization.builtins.MapSerializer(
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
kotlinx.serialization.builtins.serializer<String>(),
|
||||
),
|
||||
mapOf("productId" to config.productId),
|
||||
)
|
||||
val response = client.request("POST", "/licenses/$encoded/activate", body)
|
||||
json.decodeFromString<ActivationResult>(response)
|
||||
} catch (e: Exception) {
|
||||
ActivationResult(success = false, message = e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the status of a license key.
|
||||
*/
|
||||
suspend fun checkStatus(licenseKey: String): LicenseStatus {
|
||||
return try {
|
||||
val encoded = URLEncoder.encode(licenseKey, "UTF-8")
|
||||
val response = client.request("GET", "/licenses/$encoded/status")
|
||||
json.decodeFromString<LicenseStatus>(response)
|
||||
} catch (e: Exception) {
|
||||
LicenseStatus(valid = false, message = e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate a license key.
|
||||
*/
|
||||
suspend fun deactivate(licenseKey: String): Boolean {
|
||||
return try {
|
||||
val encoded = URLEncoder.encode(licenseKey, "UTF-8")
|
||||
client.request("POST", "/licenses/$encoded/deactivate")
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
package com.bytelyst.platform
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Generic HTTP client for platform-service.
|
||||
*
|
||||
* Injects auth token, x-product-id, and x-request-id on every request.
|
||||
* Supports fire-and-forget mode for telemetry-style calls.
|
||||
*/
|
||||
class BLPlatformClient(
|
||||
private val config: BLPlatformConfig,
|
||||
private val tokenProvider: () -> String? = { null },
|
||||
) {
|
||||
val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(config.timeoutMs, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(config.timeoutMs, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(config.timeoutMs, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Execute a request and return the response body as a String.
|
||||
* Throws on non-2xx responses.
|
||||
*/
|
||||
suspend fun request(
|
||||
method: String,
|
||||
path: String,
|
||||
body: String? = null,
|
||||
extraHeaders: Map<String, String> = emptyMap(),
|
||||
skipAuth: Boolean = false,
|
||||
): String = withContext(Dispatchers.IO) {
|
||||
val builder = Request.Builder()
|
||||
.url("${config.baseUrl}$path")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Product-Id", config.productId)
|
||||
.header("X-Request-Id", UUID.randomUUID().toString())
|
||||
|
||||
if (!skipAuth) {
|
||||
tokenProvider()?.let { token ->
|
||||
builder.header("Authorization", "Bearer $token")
|
||||
}
|
||||
}
|
||||
|
||||
extraHeaders.forEach { (k, v) -> builder.header(k, v) }
|
||||
|
||||
val requestBody = body?.toRequestBody("application/json".toMediaType())
|
||||
when (method.uppercase()) {
|
||||
"GET" -> builder.get()
|
||||
"POST" -> builder.post(requestBody ?: "".toRequestBody(null))
|
||||
"PUT" -> builder.put(requestBody ?: "".toRequestBody(null))
|
||||
"DELETE" -> if (requestBody != null) builder.delete(requestBody) else builder.delete()
|
||||
"PATCH" -> builder.patch(requestBody ?: "".toRequestBody(null))
|
||||
else -> builder.method(method, requestBody)
|
||||
}
|
||||
|
||||
val response = httpClient.newCall(builder.build()).execute()
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw BLApiException(response.code, responseBody)
|
||||
}
|
||||
|
||||
responseBody
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget: execute request, silently swallow errors.
|
||||
*/
|
||||
suspend fun fireAndForget(
|
||||
method: String,
|
||||
path: String,
|
||||
body: String? = null,
|
||||
extraHeaders: Map<String, String> = emptyMap(),
|
||||
) {
|
||||
try {
|
||||
request(method, path, body, extraHeaders)
|
||||
} catch (_: Exception) {
|
||||
// Silently swallow — fire-and-forget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception thrown when the platform API returns a non-2xx status.
|
||||
*/
|
||||
class BLApiException(
|
||||
val statusCode: Int,
|
||||
val responseBody: String,
|
||||
) : Exception("Platform API error $statusCode: $responseBody")
|
||||
@ -0,0 +1,27 @@
|
||||
package com.bytelyst.platform
|
||||
|
||||
/**
|
||||
* Product-specific configuration for the ByteLyst platform SDK.
|
||||
*
|
||||
* Each Android app creates one instance at startup and passes it to
|
||||
* all SDK components via DI (Hilt, Koin, or manual).
|
||||
*/
|
||||
data class BLPlatformConfig(
|
||||
/** Product identifier (e.g., "chronomind", "lysnrai", "mindlyst"). */
|
||||
val productId: String,
|
||||
|
||||
/** Platform-service base URL (e.g., "http://localhost:4003/api"). */
|
||||
val baseUrl: String,
|
||||
|
||||
/** Platform string for telemetry (e.g., "android", "wear_os"). */
|
||||
val platform: String = "android",
|
||||
|
||||
/** Channel string for telemetry (e.g., "native", "keyboard"). */
|
||||
val channel: String = "native",
|
||||
|
||||
/** Application ID / bundle ID (e.g., "com.chronomind.app"). */
|
||||
val applicationId: String,
|
||||
|
||||
/** Request timeout in milliseconds. Default: 15 seconds. */
|
||||
val timeoutMs: Long = 15_000L,
|
||||
)
|
||||
@ -0,0 +1,51 @@
|
||||
package com.bytelyst.platform
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKeys
|
||||
|
||||
/**
|
||||
* EncryptedSharedPreferences-backed secure storage.
|
||||
*
|
||||
* Replaces Keychain on iOS. Uses Android Keystore for key material.
|
||||
* Each app gets its own namespace via [applicationId].
|
||||
*/
|
||||
class BLSecureStore(
|
||||
context: Context,
|
||||
applicationId: String,
|
||||
) {
|
||||
private val prefs: SharedPreferences = try {
|
||||
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
|
||||
EncryptedSharedPreferences.create(
|
||||
"${applicationId}_secure_store",
|
||||
masterKeyAlias,
|
||||
context,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// Fallback to plain SharedPreferences if encryption fails (e.g., test environment)
|
||||
context.getSharedPreferences("${applicationId}_secure_store_fallback", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun save(key: String, value: String): Boolean {
|
||||
return prefs.edit().putString(key, value).commit()
|
||||
}
|
||||
|
||||
fun read(key: String): String? {
|
||||
return prefs.getString(key, null)
|
||||
}
|
||||
|
||||
fun delete(key: String): Boolean {
|
||||
return prefs.edit().remove(key).commit()
|
||||
}
|
||||
|
||||
fun clear(): Boolean {
|
||||
return prefs.edit().clear().commit()
|
||||
}
|
||||
|
||||
fun contains(key: String): Boolean {
|
||||
return prefs.contains(key)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
package com.bytelyst.platform
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Generic offline-first sync engine.
|
||||
*
|
||||
* Apps implement [BLSyncAdapter] for their specific data type,
|
||||
* then [BLSyncEngine] handles the pull-merge-push cycle.
|
||||
*
|
||||
* Mirrors the Swift BLSyncEngine + BLSyncAdapter API.
|
||||
*/
|
||||
class BLSyncEngine<T>(
|
||||
private val adapter: BLSyncAdapter<T>,
|
||||
private val config: BLPlatformConfig,
|
||||
tokenProvider: () -> String? = { null },
|
||||
) {
|
||||
private val client = BLPlatformClient(config, tokenProvider)
|
||||
|
||||
/**
|
||||
* Run a full sync cycle: pull → merge → push.
|
||||
*
|
||||
* @return [SyncResult] with counts of pulled, pushed, and conflicted items.
|
||||
*/
|
||||
suspend fun sync(): SyncResult = withContext(Dispatchers.IO) {
|
||||
var pulled = 0
|
||||
var pushed = 0
|
||||
var conflicts = 0
|
||||
|
||||
try {
|
||||
// Step 1: Pull remote changes
|
||||
val lastSync = adapter.getLastSyncTimestamp()
|
||||
val pullPath = adapter.pullPath(lastSync)
|
||||
val response = client.request("GET", pullPath)
|
||||
val remoteItems = adapter.deserializePullResponse(response)
|
||||
pulled = remoteItems.size
|
||||
|
||||
// Step 2: Merge remote into local
|
||||
for (item in remoteItems) {
|
||||
val merged = adapter.merge(item)
|
||||
if (!merged) conflicts++
|
||||
}
|
||||
|
||||
// Step 3: Push local changes
|
||||
val localChanges = adapter.getLocalChanges()
|
||||
for (item in localChanges) {
|
||||
try {
|
||||
val body = adapter.serializeForPush(item)
|
||||
val method = adapter.pushMethod(item)
|
||||
val path = adapter.pushPath(item)
|
||||
client.request(method, path, body)
|
||||
adapter.markSynced(item)
|
||||
pushed++
|
||||
} catch (_: Exception) {
|
||||
// Individual push failure — will retry next sync
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Update last sync timestamp
|
||||
adapter.setLastSyncTimestamp(System.currentTimeMillis())
|
||||
} catch (_: Exception) {
|
||||
// Full sync failure — will retry next time
|
||||
}
|
||||
|
||||
SyncResult(pulled = pulled, pushed = pushed, conflicts = conflicts)
|
||||
}
|
||||
|
||||
data class SyncResult(
|
||||
val pulled: Int = 0,
|
||||
val pushed: Int = 0,
|
||||
val conflicts: Int = 0,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter interface for [BLSyncEngine].
|
||||
*
|
||||
* Each app implements this for their domain model (timers, sessions, etc.).
|
||||
*/
|
||||
interface BLSyncAdapter<T> {
|
||||
/** Get the timestamp of the last successful sync (epoch millis), or null if never synced. */
|
||||
fun getLastSyncTimestamp(): Long?
|
||||
|
||||
/** Set the last sync timestamp after a successful sync. */
|
||||
fun setLastSyncTimestamp(timestamp: Long)
|
||||
|
||||
/** Build the pull endpoint path, optionally including a since parameter. */
|
||||
fun pullPath(since: Long?): String
|
||||
|
||||
/** Deserialize the pull response JSON into a list of remote items. */
|
||||
fun deserializePullResponse(json: String): List<T>
|
||||
|
||||
/** Merge a remote item into local storage. Return true if merged cleanly, false if conflict. */
|
||||
fun merge(remoteItem: T): Boolean
|
||||
|
||||
/** Get local items that have been modified since last sync. */
|
||||
fun getLocalChanges(): List<T>
|
||||
|
||||
/** Serialize a local item for push. */
|
||||
fun serializeForPush(item: T): String
|
||||
|
||||
/** HTTP method for pushing an item (POST for new, PUT for update). */
|
||||
fun pushMethod(item: T): String
|
||||
|
||||
/** Build the push endpoint path for an item. */
|
||||
fun pushPath(item: T): String
|
||||
|
||||
/** Mark an item as synced (clear dirty flag). */
|
||||
fun markSynced(item: T)
|
||||
}
|
||||
@ -0,0 +1,191 @@
|
||||
package com.bytelyst.platform
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Telemetry client for platform-service.
|
||||
*
|
||||
* Queues events locally and flushes in batches to POST /api/telemetry/events.
|
||||
* Events persist in SharedPreferences to survive process death.
|
||||
* Fire-and-forget: errors never surface to the user.
|
||||
*
|
||||
* Mirrors the Swift BLTelemetryClient API.
|
||||
*/
|
||||
class BLTelemetryClient(
|
||||
private val config: BLPlatformConfig,
|
||||
context: Context,
|
||||
private val maxQueueSize: Int = 50,
|
||||
private val flushIntervalMs: Long = 30_000L,
|
||||
) {
|
||||
// ── Event schema ─────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class TelemetryEvent(
|
||||
val id: String,
|
||||
val productId: String,
|
||||
val anonymousInstallId: String,
|
||||
val sessionId: String,
|
||||
val platform: String,
|
||||
val channel: String,
|
||||
val osFamily: String,
|
||||
val osVersion: String,
|
||||
val appVersion: String,
|
||||
val buildNumber: String,
|
||||
val releaseChannel: String,
|
||||
val eventType: String,
|
||||
val module: String,
|
||||
val eventName: String,
|
||||
val feature: String? = null,
|
||||
val message: String? = null,
|
||||
val errorCode: String? = null,
|
||||
val errorDomain: String? = null,
|
||||
val tags: Map<String, String>? = null,
|
||||
val metrics: Map<String, Double>? = null,
|
||||
val occurredAt: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class EventBatch(val events: List<TelemetryEvent>)
|
||||
|
||||
// ── State ────────────────────────────────────────────────
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("${config.productId}_telemetry", Context.MODE_PRIVATE)
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
private val queue = mutableListOf<TelemetryEvent>()
|
||||
private var flushJob: Job? = null
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private var sessionId = UUID.randomUUID().toString()
|
||||
|
||||
private val client = BLPlatformClient(config)
|
||||
|
||||
private val installId: String by lazy {
|
||||
val key = "install_id"
|
||||
prefs.getString(key, null) ?: UUID.randomUUID().toString().also {
|
||||
prefs.edit().putString(key, it).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private val osVersion = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
|
||||
private val appVersion: String
|
||||
private val buildNumber: String
|
||||
|
||||
init {
|
||||
val pm = context.packageManager
|
||||
val pi = try { pm.getPackageInfo(context.packageName, 0) } catch (_: Exception) { null }
|
||||
appVersion = pi?.versionName ?: "0.0.0"
|
||||
buildNumber = (pi?.longVersionCode ?: 0).toString()
|
||||
}
|
||||
|
||||
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────
|
||||
|
||||
fun start() {
|
||||
if (flushJob != null) return
|
||||
flushJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(flushIntervalMs)
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
flush()
|
||||
flushJob?.cancel()
|
||||
flushJob = null
|
||||
}
|
||||
|
||||
fun newSession() {
|
||||
sessionId = UUID.randomUUID().toString()
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────
|
||||
|
||||
fun trackEvent(
|
||||
eventType: String,
|
||||
module: String,
|
||||
name: String,
|
||||
feature: String? = null,
|
||||
message: String? = null,
|
||||
errorCode: String? = null,
|
||||
errorDomain: String? = null,
|
||||
tags: Map<String, String>? = null,
|
||||
metrics: Map<String, Double>? = null,
|
||||
) {
|
||||
val event = TelemetryEvent(
|
||||
id = UUID.randomUUID().toString(),
|
||||
productId = config.productId,
|
||||
anonymousInstallId = installId,
|
||||
sessionId = sessionId,
|
||||
platform = config.platform,
|
||||
channel = config.channel,
|
||||
osFamily = "android",
|
||||
osVersion = osVersion,
|
||||
appVersion = appVersion,
|
||||
buildNumber = buildNumber,
|
||||
releaseChannel = "beta",
|
||||
eventType = eventType,
|
||||
module = module,
|
||||
eventName = name,
|
||||
feature = feature,
|
||||
message = message?.take(512),
|
||||
errorCode = errorCode,
|
||||
errorDomain = errorDomain,
|
||||
tags = tags,
|
||||
metrics = metrics,
|
||||
occurredAt = isoFormat.format(Date()),
|
||||
)
|
||||
|
||||
synchronized(queue) {
|
||||
queue.add(event)
|
||||
if (queue.size >= maxQueueSize) {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun trackScreen(screen: String) {
|
||||
trackEvent("info", "navigation", "screen_view", tags = mapOf("screen" to screen))
|
||||
}
|
||||
|
||||
// ── Flush ────────────────────────────────────────────────
|
||||
|
||||
fun flush() {
|
||||
val batch: List<TelemetryEvent>
|
||||
synchronized(queue) {
|
||||
if (queue.isEmpty()) return
|
||||
batch = queue.toList()
|
||||
queue.clear()
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
val body = json.encodeToString(EventBatch(batch))
|
||||
client.fireAndForget(
|
||||
"POST", "/telemetry/events", body,
|
||||
extraHeaders = mapOf("X-Install-Token" to installId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/offline-queue/package.json
Normal file
21
packages/offline-queue/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@bytelyst/offline-queue",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Browser/React Native-safe persistent offline retry queue",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
143
packages/offline-queue/src/index.test.ts
Normal file
143
packages/offline-queue/src/index.test.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createOfflineQueue } from './index.js';
|
||||
|
||||
function createMemoryStorage() {
|
||||
const store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
store[k] = v;
|
||||
},
|
||||
_store: store,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createOfflineQueue', () => {
|
||||
it('should start with zero length', () => {
|
||||
const storage = createMemoryStorage();
|
||||
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
|
||||
expect(queue.length()).toBe(0);
|
||||
});
|
||||
|
||||
it('should enqueue items', () => {
|
||||
const storage = createMemoryStorage();
|
||||
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
|
||||
|
||||
queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { name: 'test' } });
|
||||
expect(queue.length()).toBe(1);
|
||||
|
||||
queue.enqueue({ id: '2', action: 'update', path: '/sessions/2', payload: { name: 'test2' } });
|
||||
expect(queue.length()).toBe(2);
|
||||
});
|
||||
|
||||
it('should replace existing entry with same id + action', () => {
|
||||
const storage = createMemoryStorage();
|
||||
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
|
||||
|
||||
queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { v: 1 } });
|
||||
queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { v: 2 } });
|
||||
expect(queue.length()).toBe(1);
|
||||
});
|
||||
|
||||
it('should not replace entries with different action', () => {
|
||||
const storage = createMemoryStorage();
|
||||
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
|
||||
|
||||
queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: {} });
|
||||
queue.enqueue({ id: '1', action: 'update', path: '/sessions/1', payload: {} });
|
||||
expect(queue.length()).toBe(2);
|
||||
});
|
||||
|
||||
it('should flush all items successfully', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
|
||||
|
||||
queue.enqueue({ id: '1', action: 'create', path: '/a', payload: { n: 1 } });
|
||||
queue.enqueue({ id: '2', action: 'create', path: '/b', payload: { n: 2 } });
|
||||
|
||||
const executor = vi.fn().mockResolvedValue(undefined);
|
||||
const result = await queue.flush(executor);
|
||||
|
||||
expect(result.flushed).toBe(2);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(queue.length()).toBe(0);
|
||||
expect(executor).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry failed items on next flush', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxRetries: 3 });
|
||||
|
||||
queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} });
|
||||
|
||||
const failingExecutor = vi.fn().mockRejectedValue(new Error('offline'));
|
||||
const result = await queue.flush(failingExecutor);
|
||||
|
||||
expect(result.flushed).toBe(0);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(queue.length()).toBe(1);
|
||||
});
|
||||
|
||||
it('should drop items after max retries', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxRetries: 2 });
|
||||
|
||||
queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} });
|
||||
|
||||
const failingExecutor = vi.fn().mockRejectedValue(new Error('offline'));
|
||||
|
||||
// Retry 1
|
||||
await queue.flush(failingExecutor);
|
||||
expect(queue.length()).toBe(1);
|
||||
|
||||
// Retry 2 — should be dropped
|
||||
await queue.flush(failingExecutor);
|
||||
expect(queue.length()).toBe(0);
|
||||
});
|
||||
|
||||
it('should cap queue at maxQueueSize', () => {
|
||||
const storage = createMemoryStorage();
|
||||
const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxQueueSize: 3 });
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
queue.enqueue({ id: `${i}`, action: 'create', path: `/item/${i}`, payload: {} });
|
||||
}
|
||||
|
||||
expect(queue.length()).toBe(3);
|
||||
});
|
||||
|
||||
it('should persist to storage', () => {
|
||||
const storage = createMemoryStorage();
|
||||
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
|
||||
|
||||
queue.enqueue({ id: '1', action: 'create', path: '/a', payload: { x: 1 } });
|
||||
|
||||
const stored = JSON.parse(storage._store['test-q']);
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('should clear the queue', () => {
|
||||
const storage = createMemoryStorage();
|
||||
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
|
||||
|
||||
queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} });
|
||||
queue.enqueue({ id: '2', action: 'create', path: '/b', payload: {} });
|
||||
expect(queue.length()).toBe(2);
|
||||
|
||||
queue.clear();
|
||||
expect(queue.length()).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty result when flushing empty queue', async () => {
|
||||
const storage = createMemoryStorage();
|
||||
const queue = createOfflineQueue({ storageKey: 'test-q', storage });
|
||||
|
||||
const executor = vi.fn();
|
||||
const result = await queue.flush(executor);
|
||||
|
||||
expect(result.flushed).toBe(0);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(executor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
166
packages/offline-queue/src/index.ts
Normal file
166
packages/offline-queue/src/index.ts
Normal file
@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Persistent offline retry queue for browser and React Native.
|
||||
*
|
||||
* When an API call fails (offline, timeout, etc.), the operation is
|
||||
* queued in configurable storage and retried on the next flush.
|
||||
*
|
||||
* No Node.js, React, or React Native dependencies.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createOfflineQueue } from '@bytelyst/offline-queue';
|
||||
*
|
||||
* const queue = createOfflineQueue({
|
||||
* storageKey: 'nomgap-offline-queue',
|
||||
* storage: mmkvStorage, // or localStorage
|
||||
* });
|
||||
*
|
||||
* // On API failure:
|
||||
* queue.enqueue({ id: 'sess-1', action: 'create', path: '/sessions', payload: { ... } });
|
||||
*
|
||||
* // On app foreground / auth success:
|
||||
* const result = await queue.flush(async (action, path, payload) => {
|
||||
* await apiClient.request(action === 'create' ? 'POST' : 'PUT', path, payload);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────
|
||||
|
||||
export interface QueueStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
|
||||
export interface OfflineQueueConfig {
|
||||
/** Storage key for persisting the queue. */
|
||||
storageKey: string;
|
||||
|
||||
/** Storage adapter (localStorage, MMKV, AsyncStorage wrapper, etc.). */
|
||||
storage: QueueStorage;
|
||||
|
||||
/** Maximum retry attempts per item. Default: 5. */
|
||||
maxRetries?: number;
|
||||
|
||||
/** Maximum queue size. Oldest items are dropped when exceeded. Default: 50. */
|
||||
maxQueueSize?: number;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
action: string;
|
||||
path: string;
|
||||
payload: Record<string, unknown>;
|
||||
enqueuedAt: number;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
export interface FlushResult {
|
||||
flushed: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface OfflineQueue {
|
||||
/** Enqueue a failed operation for later retry. Replaces existing entry with same id + action. */
|
||||
enqueue(item: {
|
||||
id: string;
|
||||
action: string;
|
||||
path: string;
|
||||
payload: Record<string, unknown>;
|
||||
}): void;
|
||||
|
||||
/** Flush the queue — retry all pending items via the provided executor. */
|
||||
flush(
|
||||
executor: (action: string, path: string, payload: Record<string, unknown>) => Promise<void>
|
||||
): Promise<FlushResult>;
|
||||
|
||||
/** Get current queue length. */
|
||||
length(): number;
|
||||
|
||||
/** Clear the entire queue. */
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
// ── Factory ──────────────────────────────────────────────────
|
||||
|
||||
export function createOfflineQueue(config: OfflineQueueConfig): OfflineQueue {
|
||||
const { storageKey, storage, maxRetries = 5, maxQueueSize = 50 } = config;
|
||||
|
||||
function loadQueue(): QueueItem[] {
|
||||
try {
|
||||
const raw = storage.getItem(storageKey);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as QueueItem[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveQueue(queue: QueueItem[]): void {
|
||||
try {
|
||||
storage.setItem(storageKey, JSON.stringify(queue));
|
||||
} catch {
|
||||
// Storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(item: {
|
||||
id: string;
|
||||
action: string;
|
||||
path: string;
|
||||
payload: Record<string, unknown>;
|
||||
}): void {
|
||||
const queue = loadQueue();
|
||||
|
||||
// Replace existing entry for same entity + action
|
||||
const filtered = queue.filter(q => !(q.id === item.id && q.action === item.action));
|
||||
|
||||
// Cap queue size
|
||||
if (filtered.length >= maxQueueSize) {
|
||||
filtered.shift();
|
||||
}
|
||||
|
||||
filtered.push({
|
||||
...item,
|
||||
enqueuedAt: Date.now(),
|
||||
retryCount: 0,
|
||||
});
|
||||
|
||||
saveQueue(filtered);
|
||||
}
|
||||
|
||||
async function flush(
|
||||
executor: (action: string, path: string, payload: Record<string, unknown>) => Promise<void>
|
||||
): Promise<FlushResult> {
|
||||
const queue = loadQueue();
|
||||
if (queue.length === 0) return { flushed: 0, failed: 0 };
|
||||
|
||||
let flushed = 0;
|
||||
const remaining: QueueItem[] = [];
|
||||
|
||||
for (const item of queue) {
|
||||
try {
|
||||
await executor(item.action, item.path, item.payload);
|
||||
flushed++;
|
||||
} catch {
|
||||
if (item.retryCount + 1 < maxRetries) {
|
||||
remaining.push({ ...item, retryCount: item.retryCount + 1 });
|
||||
}
|
||||
// else: silently drop — too many retries
|
||||
}
|
||||
}
|
||||
|
||||
saveQueue(remaining);
|
||||
return { flushed, failed: remaining.length };
|
||||
}
|
||||
|
||||
function length(): number {
|
||||
return loadQueue().length;
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
saveQueue([]);
|
||||
}
|
||||
|
||||
return { enqueue, flush, length, clear };
|
||||
}
|
||||
8
packages/offline-queue/tsconfig.json
Normal file
8
packages/offline-queue/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
21
packages/platform-client/package.json
Normal file
21
packages/platform-client/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@bytelyst/platform-client",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Browser/React Native-safe typed fetch wrapper for platform-service",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
162
packages/platform-client/src/index.test.ts
Normal file
162
packages/platform-client/src/index.test.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { createPlatformClient, ApiError } from './index.js';
|
||||
|
||||
describe('createPlatformClient', () => {
|
||||
const baseConfig = {
|
||||
baseUrl: 'http://localhost:4003/api',
|
||||
productId: 'testapp',
|
||||
getAccessToken: () => 'test-token',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should make GET requests with auth header', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ items: [] }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const api = createPlatformClient(baseConfig);
|
||||
const result = await api.get('/items');
|
||||
|
||||
expect(result).toEqual({ items: [] });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:4003/api/items',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer test-token',
|
||||
'x-product-id': 'testapp',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should make POST requests with body', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ id: '1' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const api = createPlatformClient(baseConfig);
|
||||
const result = await api.post('/items', { name: 'test' });
|
||||
|
||||
expect(result).toEqual({ id: '1' });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:4003/api/items',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: 'test' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle 204 No Content', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 204,
|
||||
json: () => Promise.reject(new Error('no body')),
|
||||
})
|
||||
);
|
||||
|
||||
const api = createPlatformClient(baseConfig);
|
||||
const result = await api.del('/items/1');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw ApiError on non-OK response', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () => Promise.resolve({ message: 'Bad request' }),
|
||||
})
|
||||
);
|
||||
|
||||
const api = createPlatformClient(baseConfig);
|
||||
|
||||
await expect(api.get('/items')).rejects.toThrow(ApiError);
|
||||
try {
|
||||
await api.get('/items');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ApiError);
|
||||
expect((e as ApiError).status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it('should attempt token refresh on 401', async () => {
|
||||
let callCount = 0;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: 'Unauthorized' }),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ refreshed: true }),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const refreshFn = vi.fn().mockResolvedValue(true);
|
||||
const api = createPlatformClient({
|
||||
...baseConfig,
|
||||
refreshAccessToken: refreshFn,
|
||||
});
|
||||
|
||||
const result = await api.get('/items');
|
||||
expect(refreshFn).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({ refreshed: true });
|
||||
});
|
||||
|
||||
it('should not include auth header when token is null', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const api = createPlatformClient({
|
||||
...baseConfig,
|
||||
getAccessToken: () => null,
|
||||
});
|
||||
await api.get('/public/items');
|
||||
|
||||
const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>;
|
||||
expect(headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include x-request-id header', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const api = createPlatformClient(baseConfig);
|
||||
await api.get('/items');
|
||||
|
||||
const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>;
|
||||
expect(headers['x-request-id']).toBeDefined();
|
||||
expect(headers['x-request-id'].length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
156
packages/platform-client/src/index.ts
Normal file
156
packages/platform-client/src/index.ts
Normal file
@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Browser/React Native-safe typed fetch wrapper for platform-service.
|
||||
*
|
||||
* Client-side counterpart to @bytelyst/api-client (which is server-side).
|
||||
* Uses bearer tokens from storage (not httpOnly cookies).
|
||||
* Includes auto-retry on 401 via token refresh.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createPlatformClient } from '@bytelyst/platform-client';
|
||||
*
|
||||
* const api = createPlatformClient({
|
||||
* baseUrl: 'http://localhost:4003/api',
|
||||
* productId: 'nomgap',
|
||||
* getAccessToken: () => authClient.getAccessToken(),
|
||||
* refreshAccessToken: () => authClient.refreshAccessToken(),
|
||||
* });
|
||||
*
|
||||
* const sessions = await api.get<Session[]>('/fasting-sessions');
|
||||
* await api.post('/fasting-sessions', { protocol: '16:8' });
|
||||
* ```
|
||||
*/
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────
|
||||
|
||||
export interface PlatformClientConfig {
|
||||
/** Platform-service base URL (e.g. "http://localhost:4003/api"). */
|
||||
baseUrl: string;
|
||||
|
||||
/** Product identifier sent as x-product-id header. */
|
||||
productId: string;
|
||||
|
||||
/** Function that returns the current access token, or null. */
|
||||
getAccessToken: () => string | null;
|
||||
|
||||
/** Optional function to refresh the access token. Returns true on success. */
|
||||
refreshAccessToken?: () => Promise<boolean>;
|
||||
|
||||
/** Request timeout in milliseconds. Default: 15000. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly body: unknown,
|
||||
message?: string
|
||||
) {
|
||||
super(message ?? `API error ${status}`);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface PlatformClient {
|
||||
get<T = unknown>(path: string, headers?: Record<string, string>): Promise<T>;
|
||||
post<T = unknown>(path: string, body?: unknown, headers?: Record<string, string>): Promise<T>;
|
||||
put<T = unknown>(path: string, body?: unknown, headers?: Record<string, string>): Promise<T>;
|
||||
del<T = unknown>(path: string, headers?: Record<string, string>): Promise<T>;
|
||||
request<T = unknown>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
headers?: Record<string, string>
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
// ── UUID helper ──────────────────────────────────────────────
|
||||
|
||||
function uuid(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Factory ──────────────────────────────────────────────────
|
||||
|
||||
export function createPlatformClient(config: PlatformClientConfig): PlatformClient {
|
||||
const { baseUrl, productId, getAccessToken, refreshAccessToken, timeoutMs = 15_000 } = config;
|
||||
|
||||
async function doRequest<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
extraHeaders?: Record<string, string>,
|
||||
isRetry = false
|
||||
): Promise<T> {
|
||||
const url = `${baseUrl}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-product-id': productId,
|
||||
'x-request-id': uuid(),
|
||||
...extraHeaders,
|
||||
};
|
||||
|
||||
const token = getAccessToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await globalThis.fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body != null ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
|
||||
const json = await res.json().catch(() => ({}));
|
||||
|
||||
// Auto-refresh on 401 (once)
|
||||
if (res.status === 401 && !isRetry && !path.startsWith('/auth/') && refreshAccessToken) {
|
||||
clearTimeout(timer);
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
return doRequest<T>(method, path, body, extraHeaders, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
json,
|
||||
(json as Record<string, string>).message ?? `HTTP ${res.status}`
|
||||
);
|
||||
}
|
||||
|
||||
return json as T;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get: <T = unknown>(path: string, headers?: Record<string, string>) =>
|
||||
doRequest<T>('GET', path, undefined, headers),
|
||||
post: <T = unknown>(path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||
doRequest<T>('POST', path, body, headers),
|
||||
put: <T = unknown>(path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||
doRequest<T>('PUT', path, body, headers),
|
||||
del: <T = unknown>(path: string, headers?: Record<string, string>) =>
|
||||
doRequest<T>('DELETE', path, undefined, headers),
|
||||
request: <T = unknown>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
headers?: Record<string, string>
|
||||
) => doRequest<T>(method, path, body, headers),
|
||||
};
|
||||
}
|
||||
8
packages/platform-client/tsconfig.json
Normal file
8
packages/platform-client/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -379,10 +379,18 @@ importers:
|
||||
specifier: ^10.6.0
|
||||
version: 10.6.0(fastify@5.7.4)
|
||||
|
||||
packages/feature-flag-client: {}
|
||||
|
||||
packages/kill-switch-client: {}
|
||||
|
||||
packages/logger: {}
|
||||
|
||||
packages/monitoring: {}
|
||||
|
||||
packages/offline-queue: {}
|
||||
|
||||
packages/platform-client: {}
|
||||
|
||||
packages/react-auth:
|
||||
dependencies:
|
||||
'@bytelyst/api-client':
|
||||
|
||||
Loading…
Reference in New Issue
Block a user