-
Notifications
You must be signed in to change notification settings - Fork 6
feat(reminder): add interval time, expo-audio and vibration adapters #215
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
gac0812
wants to merge
17
commits into
1024XEngineer:main
from
gac0812:feat/reminder-playback-adapters
Closed
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
584e28a
feat(reminder): add native device capability and permission hook
d2a63b0
feat(reminder): add interval time, expo-audio and vibration adapters
cd0dc67
Merge remote-tracking branch 'upstream/main' into feat/reminder-nativ…
ef37d59
fix(reminder): align native alarm receipts with scheduled contract
f9e3e6f
Merge remote-tracking branch 'upstream/main' into feat/reminder-nativ…
gac0812 b6068c1
Merge branch 'feat/reminder-native-alarm' into feat/reminder-native-d…
gac0812 6427d98
Merge branch 'feat/reminder-native-device-permissions' into feat/remi…
gac0812 baa5504
test(reminder): cover native alarm scheduler and bridge
gac0812 10e7814
Merge remote-tracking branch 'origin/feat/reminder-native-alarm' into…
gac0812 fca8bb2
test(reminder): cover native device capability and launch permission …
gac0812 92db91f
Merge remote-tracking branch 'origin/feat/reminder-native-device-perm…
gac0812 e84b6eb
test(reminder): cover interval time, audio and vibration adapters
gac0812 b519083
Merge remote-tracking branch 'upstream/main' into feat/reminder-playb…
gac0812 833a520
fix(reminder): keep playback ports mocked until the local application…
gac0812 5620577
fix(audio): report playback failure instead of assuming play() succeeded
gac0812 51dd6cc
fix(audio): cancel playing wait when native play() throws
gac0812 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| 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; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)}`; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
16
frontend/src/infrastructure/notifications/ReactNativeVibration.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.