Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f4bc93a
feat(reminder): add native alarm scheduler adapter
Aug 12, 2026
584e28a
feat(reminder): add native device capability and permission hook
Aug 12, 2026
d2a63b0
feat(reminder): add interval time, expo-audio and vibration adapters
Aug 12, 2026
cd0dc67
Merge remote-tracking branch 'upstream/main' into feat/reminder-nativ…
Aug 13, 2026
ef37d59
fix(reminder): align native alarm receipts with scheduled contract
Aug 13, 2026
f9e3e6f
Merge remote-tracking branch 'upstream/main' into feat/reminder-nativ…
gac0812 Aug 13, 2026
b6068c1
Merge branch 'feat/reminder-native-alarm' into feat/reminder-native-d…
gac0812 Aug 13, 2026
6427d98
Merge branch 'feat/reminder-native-device-permissions' into feat/remi…
gac0812 Aug 13, 2026
baa5504
test(reminder): cover native alarm scheduler and bridge
gac0812 Aug 13, 2026
10e7814
Merge remote-tracking branch 'origin/feat/reminder-native-alarm' into…
gac0812 Aug 13, 2026
fca8bb2
test(reminder): cover native device capability and launch permission …
gac0812 Aug 13, 2026
92db91f
Merge remote-tracking branch 'origin/feat/reminder-native-device-perm…
gac0812 Aug 13, 2026
e84b6eb
test(reminder): cover interval time, audio and vibration adapters
gac0812 Aug 13, 2026
b519083
Merge remote-tracking branch 'upstream/main' into feat/reminder-playb…
gac0812 Aug 13, 2026
833a520
fix(reminder): keep playback ports mocked until the local application…
gac0812 Aug 13, 2026
5620577
fix(audio): report playback failure instead of assuming play() succeeded
gac0812 Aug 14, 2026
51dd6cc
fix(audio): cancel playing wait when native play() throws
gac0812 Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 42 additions & 28 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@expo/metro-runtime": "~57.0.8",
"@irvingouj/expo-audio-stream": "3.1.0",
"expo": "~57.0.7",
"expo-audio": "~57.0.3",
"expo-location": "~57.0.9",
"expo-secure-store": "~57.0.1",
"expo-sqlite": "~57.0.1",
Expand Down
196 changes: 196 additions & 0 deletions frontend/src/infrastructure/audio/ExpoAudioPlayback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import type {
AudioPlaybackPort,
AudioPlaybackReceipt,
AudioPlaybackRequest,
} from '../../features/reminder/application/interfaces';
import { buildAudioDataUri } from './audioDataUri';

type AudioStatusLike = {
playing?: boolean;
error?: string | null;
};

type AudioPlayerSubscription = {
remove: () => void;
};

type AudioPlayerLike = {
pause: () => void;
replace: (source: string) => void;
play: () => void;
volume: number;
playing?: boolean;
addListener?: (
eventName: 'playbackStatusUpdate',
listener: (status: AudioStatusLike) => void,
) => AudioPlayerSubscription;
};

type ExpoAudioModule = {
createAudioPlayer: (source?: string | null) => AudioPlayerLike;
setAudioModeAsync: (mode: Record<string, unknown>) => Promise<void>;
};

const PLAYING_CONFIRM_MS = 2_000;

/**
* 音频播放适配器:在已安装 expo-audio 时播放 data URI;
* 否则标记为本地兜底占位(不抛错,便于无原生依赖环境开发)。
*/
export class ExpoAudioPlayback implements AudioPlaybackPort {
private player: AudioPlayerLike | null = null;
private activeScheduleId: string | null = null;
private modeReady: Promise<void> | null = null;

async isTtsAvailable(): Promise<boolean> {
// TTS 字节管线尚未接入;有 data 时由 playTts 直接播放。
return false;
}

async playTts(request: AudioPlaybackRequest): Promise<AudioPlaybackReceipt> {
if (request.data == null || request.data.byteLength === 0) {
return {
playback_id: `tts-empty-${request.schedule_id}`,
played: false,
used_local_fallback: false,
};
}
const played = await this.playBytes(request.schedule_id, request.data, request.format ?? 'wav');
return {
playback_id: `tts-${request.schedule_id}`,
played,
used_local_fallback: false,
};
}

async playLocalFallback(request: AudioPlaybackRequest): Promise<AudioPlaybackReceipt> {
if (request.data != null && request.data.byteLength > 0) {
const played = await this.playBytes(
request.schedule_id,
request.data,
request.format ?? 'wav',
);
return {
playback_id: `local-${request.schedule_id}`,
played,
used_local_fallback: true,
};
}
return {
playback_id: `local-placeholder-${request.schedule_id}`,
played: false,
used_local_fallback: true,
};
}

async stop(scheduleId: string): Promise<void> {
if (this.activeScheduleId !== scheduleId) return;
this.player?.pause();
this.activeScheduleId = null;
}

private async playBytes(scheduleId: string, data: Uint8Array, format: string): Promise<boolean> {
const expoAudio = loadExpoAudio();
if (expoAudio == null) return false;

let cancelWait: (() => void) | undefined;
try {
const modeOk = await this.ensureAudioMode(expoAudio);
if (!modeOk) return false;

if (this.player == null) {
this.player = expoAudio.createAudioPlayer(null);
}

this.player.pause();
this.player.replace(buildAudioDataUri(data, format));
this.player.volume = 1;
this.activeScheduleId = scheduleId;
const wait = this.waitUntilPlaying(this.player);
cancelWait = wait.cancel;
this.player.play();
Comment thread
gac0812 marked this conversation as resolved.
const played = await wait.promise;
if (this.activeScheduleId !== scheduleId) return false;
return played;
} catch {
cancelWait?.();
if (this.activeScheduleId === scheduleId) {
this.activeScheduleId = null;
}
return false;
}
}

private async ensureAudioMode(expoAudio: ExpoAudioModule): Promise<boolean> {
if (this.modeReady == null) {
this.modeReady = expoAudio.setAudioModeAsync({
allowsRecording: false,
interruptionMode: 'doNotMix',
playsInSilentMode: true,
shouldPlayInBackground: false,
shouldRouteThroughEarpiece: false,
});
}
try {
await this.modeReady;
return true;
} catch {
this.modeReady = null;
return false;
}
}

private waitUntilPlaying(player: AudioPlayerLike): {
promise: Promise<boolean>;
cancel: () => void;
} {
let cancel = (): void => undefined;
const promise = new Promise<boolean>((resolve) => {
let settled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let subscription: AudioPlayerSubscription | undefined;
const finish = (value: boolean) => {
if (settled) return;
settled = true;
if (timeoutId != null) clearTimeout(timeoutId);
subscription?.remove();
resolve(value);
};
cancel = () => finish(false);
if (player.playing === true) {
finish(true);
return;
}
if (typeof player.addListener !== 'function') {
finish(false);
return;
}
timeoutId = setTimeout(() => finish(false), PLAYING_CONFIRM_MS);
subscription = player.addListener('playbackStatusUpdate', (status) => {
if (status?.error) {
finish(false);
return;
}
if (status?.playing === true) {
finish(true);
}
});
});
return { promise, cancel };
}
}

function loadExpoAudio(): ExpoAudioModule | null {
try {
// Lazy require keeps Jest able to mock expo-audio without native ESM.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require('expo-audio') as {
default?: ExpoAudioModule;
} & Partial<ExpoAudioModule>;
const resolved = mod.default ?? mod;
if (typeof resolved.createAudioPlayer !== 'function') return null;
return resolved as ExpoAudioModule;
} catch {
return null;
}
}
39 changes: 39 additions & 0 deletions frontend/src/infrastructure/audio/audioDataUri.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';

const MIME_BY_FORMAT: Record<string, string> = {
aac: 'audio/aac',
m4a: 'audio/mp4',
mp3: 'audio/mpeg',
mpeg: 'audio/mpeg',
oga: 'audio/ogg',
ogg: 'audio/ogg',
wav: 'audio/wav',
wave: 'audio/wav',
};

export function encodeBase64(bytes: Uint8Array): string {
let output = '';
for (let index = 0; index < bytes.length; index += 3) {
const first = bytes[index] ?? 0;
const hasSecond = index + 1 < bytes.length;
const hasThird = index + 2 < bytes.length;
const second = hasSecond ? bytes[index + 1]! : 0;
const third = hasThird ? bytes[index + 2]! : 0;
const value = (first << 16) | (second << 8) | third;

output += BASE64_ALPHABET[(value >>> 18) & 63];
output += BASE64_ALPHABET[(value >>> 12) & 63];
output += hasSecond ? BASE64_ALPHABET[(value >>> 6) & 63] : '=';
output += hasThird ? BASE64_ALPHABET[value & 63] : '=';
}
return output;
}

export function buildAudioDataUri(bytes: Uint8Array, audioFormat: string): string {
const normalizedFormat = audioFormat.trim().toLowerCase().replace(/^\./, '');
if (!/^[a-z0-9][a-z0-9.+-]{0,31}$/.test(normalizedFormat)) {
throw new Error('Unsupported reminder audio format');
}
const mime = MIME_BY_FORMAT[normalizedFormat] ?? `audio/${normalizedFormat}`;
return `data:${mime};base64,${encodeBase64(bytes)}`;
}
2 changes: 2 additions & 0 deletions frontend/src/infrastructure/audio/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { MockAudioPlayback } from './MockAudioPlayback';
export { ExpoAudioPlayback } from './ExpoAudioPlayback';
export { buildAudioDataUri, encodeBase64 } from './audioDataUri';
16 changes: 16 additions & 0 deletions frontend/src/infrastructure/notifications/ReactNativeVibration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Vibration } from 'react-native';

import type { VibrationPort } from '../../features/reminder/application/interfaces';

const DEFAULT_PATTERN = [0, 500, 200, 500];

/** React Native Vibration 适配器。 */
export class ReactNativeVibration implements VibrationPort {
async vibrate(): Promise<void> {
Vibration.vibrate(DEFAULT_PATTERN);
}

async stop(): Promise<void> {
Vibration.cancel();
}
}
1 change: 1 addition & 0 deletions frontend/src/infrastructure/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { MockAlarmScheduler } from './MockAlarmScheduler';
export { NativeAlarmScheduler } from './NativeAlarmScheduler';
export { NativeDeviceCapability } from './NativeDeviceCapability';
export { ReactNativeVibration } from './ReactNativeVibration';
export { MockPopup, MockSystemNotification, MockVibration } from './MockNotificationChannels';
export { MockReminderRecovery } from './MockReminderRecovery';
export { MockReminderDelivery, MOCK_REMINDER_DELIVERY_RECEIPT } from './MockReminderDelivery';
Expand Down
Loading
Loading