ADD - voice memo app

This commit is contained in:
Leon.Schmidt
2026-08-13 16:16:23 +02:00
parent 223cb4a600
commit f4ed86fd63
29 changed files with 3234 additions and 50 deletions
+10 -9
View File
@@ -184,25 +184,26 @@ and restart the resource after updating the configuration.
- When `Config.Sim.Enabled = true`, two unique, non-stackable inventory items named `sky_phone_sim_registered` and `sky_phone_sim_anonymous`. Their metadata is initialized automatically on first use, so shops and crafting recipes add plain items without supplying a number. These item definitions are not required when SIM cards are disabled.
- `oxmysql` with MySQL/MariaDB.
- `pma-voice` when `Config.Calls.VoiceProvider` is set to `"pma"`.
- A FiveManage V3 Media API token for Camera photo/video uploads and Gallery deletion. Set it as a
server-only convar; the token is never sent to NUI because clients receive temporary presigned
upload URLs instead:
- A FiveManage V3 Media API token for Camera photo/video uploads, Voice Memo audio uploads, and Gallery deletion. Set it in the
server-only `sky_phone/config/media.lua`; the token is never sent to NUI because clients receive
temporary presigned upload URLs instead:
```cfg
set sky_phone_fivemanage_api_key "replace-with-your-media-token"
```lua
Config.Media.FiveManage.ApiKey = "replace-with-your-media-token"
```
- `yaca-voice`, `pma-voice`, or `saltychat` when the Radio app is enabled. `Config.Radio.VoiceProvider = "auto"` selects the first running provider in that order.
## Messages GIF provider
Configure GIF search as a server-only convar:
Configure GIF search directly in the server-only `sky_phone/config/media.lua`:
```cfg
set sky_phone_giphy_api_key "replace-with-your-giphy-api-key"
```lua
Config.Media.GiphyApiKey = "replace-with-your-giphy-api-key"
```
GIPHY provides trending and searched GIFs through a paginated server-side proxy. Only the server
reads the key. Photo and video actions in Messages use media captured by the Camera app.
reads the key. Never expose it to the client or NUI. Photo and video actions in Messages use media
captured by the Camera app.
Database migrations run automatically. Existing `sky_phone_mail_accounts` installations are renamed to `sky_phone_accounts` while preserving account IDs and mail foreign keys. The migration also creates `sky_phone_character_devices` for persistent non-unique phone mappings and marks automatic SIMs through `sky_phone_sims.is_virtual`. Sky Cloud passwords are intentional in-character credentials and remain plaintext `VARCHAR(64)` values; registration screens warn players never to reuse a real password.
+7 -1
View File
@@ -13,6 +13,7 @@ import { useRoute, useRouter } from 'vue-router'
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
import PhoneMediaCapture from '@/components/PhoneMediaCapture.vue'
import PhoneMemoRecorder from '@/components/PhoneMemoRecorder.vue'
import PhoneLockScreen from '@/components/PhoneLockScreen.vue'
import PhonePasscode from '@/components/PhonePasscode.vue'
import PhoneNotifications from '@/components/PhoneNotifications.vue'
@@ -47,6 +48,7 @@ import { useAppStoreStore } from '@/stores/app-store'
import { useWidgetsStore } from '@/stores/widgets'
import { isPhoneAppId } from '@/config/apps'
import { useNotesStore } from '@/stores/notes'
import { useMemosStore } from '@/stores/memos'
import { useWeatherStore } from '@/stores/weather'
import { useEasyShareStore } from '@/stores/easyshare'
import {
@@ -265,6 +267,7 @@ const appCatalog = useAppCatalogStore()
const appStore = useAppStoreStore()
const widgets = useWidgetsStore()
const notes = useNotesStore()
const memos = useMemosStore()
const weather = useWeatherStore()
const easyShare = useEasyShareStore()
const notifications = useNotificationsStore()
@@ -377,6 +380,7 @@ function hydratePhone(payload: PhoneOpenPayload): void {
payload.account?.email ?? '',
)
notes.hydrate(payload.notes ?? [])
memos.hydrate(payload.memos ?? [])
clock.hydrate(payload.device?.data.alarms?.payload)
games.hydrate(payload.device?.data.games?.payload)
media.hydrate(payload.device?.data.media?.payload)
@@ -454,6 +458,7 @@ async function hydrateDevelopmentPhone(): Promise<void> {
type: 'registered',
},
},
memos: [],
notes: [],
token: 'development',
})
@@ -1291,6 +1296,7 @@ onBeforeUnmount(() => {
<template>
<PhoneMediaCapture />
<PhoneMemoRecorder />
<RadioHud />
<PayphoneOverlay />
<SimPhonePicker
@@ -1419,7 +1425,7 @@ onBeforeUnmount(() => {
/>
</Transition>
<PhoneNotifications
:notification="phone.isOpen ? null : notifications.current"
:notification="notifications.current"
@close="notifications.dismissCurrent()"
@open="openNotificationPreview"
/>
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,604 @@
<script setup lang="ts">
import fixWebmDuration from 'fix-webm-duration'
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { useMemosStore } from '@/stores/memos'
import { usePhoneStore } from '@/stores/phone'
import type {
MemoDto,
MemoRecorderState,
MemoRecorderStateName,
MemoRecordingMetadata,
MemoUploadReady,
MemoUploadResult,
} from '@/types/memos'
import { isMemoDto } from '@/types/memos'
import {
bindMediaRecorderError,
stopMediaRecorder,
} from '@/utils/mediaRecorder'
import { nuiCall } from '@/utils/nui'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type PendingMemo = {
abortController?: AbortController
blob: Blob
fileName: string
requestId?: string
}
const MAX_DURATION_MS = 5 * 60 * 1000
const MAX_RECORDING_BYTES = 2 * 1024 * 1024
const WAVEFORM_SAMPLES = 96
const LIVE_LEVEL_SAMPLES = 32
const AUDIO_BITS_PER_SECOND = 24_000
const memos = useMemosStore()
const phone = usePhoneStore()
const pendingMemos = new Map<string, PendingMemo>()
let recorder: MediaRecorder | null = null
let mediaStream: MediaStream | null = null
let audioContext: AudioContext | null = null
let analyser: AnalyserNode | null = null
let recordingTimer: number | undefined
let recordingGeneration = 0
let recordingStartedAt = 0
let pausedStartedAt = 0
let totalPausedMs = 0
let recordingBytes = 0
let recordingTooLarge = false
let recordingChunks: Blob[] = []
let recordingSamples: number[] = []
let liveLevels: number[] = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
let metadata: MemoRecordingMetadata = { note: '', pinned: false, title: '' }
let currentState: MemoRecorderStateName = 'idle'
let currentElapsedMs = 0
let currentCorrelationId = ''
let removeRecorderErrorListener: (() => void) | null = null
function postRecorderState(state: MemoRecorderStateName, error?: string): void {
currentState = state
const data: MemoRecorderState = {
elapsedMs: Math.round(currentElapsedMs),
levels: [...liveLevels],
state,
...(error ? { error } : {}),
}
window.postMessage({ data, type: 'memo:recordState' }, '*')
}
function updateMetadata(data: Record<string, unknown>): void {
if (typeof data.title === 'string') metadata.title = data.title
if (typeof data.note === 'string') metadata.note = data.note
if (typeof data.pinned === 'boolean') metadata.pinned = data.pinned
}
function elapsedAt(now = performance.now()): number {
if (!recordingStartedAt) return currentElapsedMs
const activePause = pausedStartedAt ? now - pausedStartedAt : 0
return Math.max(
0,
Math.min(
MAX_DURATION_MS,
now - recordingStartedAt - totalPausedMs - activePause,
),
)
}
function recordingMime(): string | null {
if (typeof MediaRecorder === 'undefined') return null
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
return 'audio/webm;codecs=opus'
}
if (MediaRecorder.isTypeSupported('audio/webm')) return 'audio/webm'
return null
}
function sampleMicrophone(): void {
if (!analyser || currentState !== 'recording') return
const values = new Uint8Array(analyser.fftSize)
analyser.getByteTimeDomainData(values)
let total = 0
for (const value of values) total += Math.abs(value - 128) / 128
const level = Math.max(0.08, Math.min(1, (total / values.length) * 4.5))
recordingSamples.push(level)
liveLevels = [...liveLevels.slice(1), level]
currentElapsedMs = elapsedAt()
postRecorderState('recording')
if (currentElapsedMs >= MAX_DURATION_MS) void stopRecording({})
}
function compressedWaveform(): number[] {
if (!recordingSamples.length) return Array(WAVEFORM_SAMPLES).fill(0.08)
const result: number[] = []
const bucketSize = recordingSamples.length / WAVEFORM_SAMPLES
for (let index = 0; index < WAVEFORM_SAMPLES; index += 1) {
const start = Math.floor(index * bucketSize)
const end = Math.max(start + 1, Math.floor((index + 1) * bucketSize))
const bucket = recordingSamples.slice(start, end)
const average = bucket.length
? bucket.reduce((sum, value) => sum + value, 0) / bucket.length
: (recordingSamples.at(-1) ?? 0.08)
result.push(Math.max(0.08, Math.min(1, average)))
}
return result
}
function blobDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
return
}
reject(new Error('invalid_audio_data'))
})
reader.addEventListener('error', () => {
reject(reader.error ?? new Error('audio_read_failed'))
})
reader.addEventListener('abort', () =>
reject(new Error('audio_read_failed')),
)
reader.readAsDataURL(blob)
})
}
function resetRecordingData(): void {
recordingChunks = []
recordingSamples = []
recordingBytes = 0
recordingTooLarge = false
recordingStartedAt = 0
pausedStartedAt = 0
totalPausedMs = 0
currentElapsedMs = 0
liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
}
function cleanupRecorder(stopActive: boolean): void {
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
recordingTimer = undefined
removeRecorderErrorListener?.()
removeRecorderErrorListener = null
const activeRecorder = recorder
recorder = null
if (activeRecorder) {
activeRecorder.ondataavailable = null
if (stopActive && activeRecorder.state !== 'inactive') {
try {
activeRecorder.stop()
} catch (error) {
console.error('[Memos] Could not stop the media recorder.', error)
}
}
}
mediaStream?.getTracks().forEach((track) => track.stop())
mediaStream = null
void audioContext?.close()
audioContext = null
analyser = null
}
function failRecording(error: string, generation = recordingGeneration): void {
if (generation !== recordingGeneration) return
recordingGeneration += 1
cleanupRecorder(true)
resetRecordingData()
postRecorderState('error', error)
}
async function startRecording(data: Record<string, unknown>): Promise<void> {
if (!phone.isOpen) {
console.error('[Memos] Cannot start a recording while the phone is closed.')
return
}
if (!['idle', 'error'].includes(currentState)) {
console.error(
`[Memos] Cannot start while recorder state is ${currentState}.`,
)
postRecorderState(currentState, 'operation_in_progress')
return
}
if (
!navigator.mediaDevices?.getUserMedia ||
typeof MediaRecorder === 'undefined'
) {
postRecorderState('error', 'microphone_unavailable')
return
}
const mimeType = recordingMime()
if (!mimeType) {
postRecorderState('error', 'microphone_unavailable')
return
}
const generation = ++recordingGeneration
metadata = { note: '', pinned: false, title: '' }
updateMetadata(data)
resetRecordingData()
postRecorderState('starting')
try {
const acquiredStream = await navigator.mediaDevices.getUserMedia({
audio: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
},
})
if (generation !== recordingGeneration) {
acquiredStream.getTracks().forEach((track) => track.stop())
return
}
mediaStream = acquiredStream
recorder = new MediaRecorder(mediaStream, {
audioBitsPerSecond: AUDIO_BITS_PER_SECOND,
mimeType,
})
const activeRecorder = recorder
activeRecorder.ondataavailable = (event) => {
if (generation !== recordingGeneration || !event.data.size) return
recordingChunks.push(event.data)
recordingBytes += event.data.size
if (recordingBytes > MAX_RECORDING_BYTES && !recordingTooLarge) {
recordingTooLarge = true
void stopRecording({})
}
}
removeRecorderErrorListener = bindMediaRecorderError(
activeRecorder,
() => generation === recordingGeneration && recorder === activeRecorder,
(event) => {
console.error('[Memos] Media recorder failed while recording.', event)
failRecording('recording_failed', generation)
},
)
audioContext = new AudioContext()
analyser = audioContext.createAnalyser()
analyser.fftSize = 128
audioContext.createMediaStreamSource(mediaStream).connect(analyser)
activeRecorder.start(250)
recordingStartedAt = performance.now()
recordingTimer = window.setInterval(sampleMicrophone, 100)
postRecorderState('recording')
} catch (error) {
if (generation !== recordingGeneration) return
console.error('[Memos] Could not start audio recording.', error)
failRecording('microphone_unavailable', generation)
}
}
function pauseRecording(): void {
if (
!recorder ||
recorder.state !== 'recording' ||
currentState !== 'recording'
) {
console.error('[Memos] Cannot pause because no memo is being recorded.')
return
}
try {
currentElapsedMs = elapsedAt()
pausedStartedAt = performance.now()
recorder.pause()
postRecorderState('paused')
} catch (error) {
console.error('[Memos] Could not pause the media recorder.', error)
failRecording('recording_failed')
}
}
function resumeRecording(): void {
if (!recorder || recorder.state !== 'paused' || currentState !== 'paused') {
console.error('[Memos] Cannot resume because no memo recording is paused.')
return
}
try {
const now = performance.now()
totalPausedMs += pausedStartedAt ? now - pausedStartedAt : 0
pausedStartedAt = 0
recorder.resume()
currentElapsedMs = elapsedAt(now)
postRecorderState('recording')
} catch (error) {
console.error('[Memos] Could not resume the media recorder.', error)
failRecording('recording_failed')
}
}
async function stopRecording(data: Record<string, unknown>): Promise<void> {
const activeRecorder = recorder
if (
!activeRecorder ||
activeRecorder.state === 'inactive' ||
!['recording', 'paused'].includes(currentState)
) {
return
}
const generation = recordingGeneration
updateMetadata(data)
currentElapsedMs = elapsedAt()
postRecorderState('stopping')
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
recordingTimer = undefined
removeRecorderErrorListener?.()
removeRecorderErrorListener = null
try {
await stopMediaRecorder(activeRecorder)
if (generation !== recordingGeneration) return
const mimeType = activeRecorder.mimeType || 'audio/webm'
const durationMs = Math.max(300, Math.round(currentElapsedMs))
let blob = new Blob(recordingChunks, { type: mimeType })
blob = await (
fixWebmDuration as unknown as (
source: Blob,
duration: number,
options: { logger: boolean },
) => Promise<Blob>
)(blob, durationMs, { logger: false })
if (generation !== recordingGeneration) return
const waveform = compressedWaveform()
const finalMetadata = { ...metadata }
const exceededSizeLimit = recordingTooLarge
cleanupRecorder(false)
resetRecordingData()
if (exceededSizeLimit || !blob.size || blob.size > MAX_RECORDING_BYTES) {
postRecorderState('error', 'recording_too_large')
return
}
const correlationId = `memo-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
currentCorrelationId = correlationId
currentElapsedMs = durationMs
liveLevels = waveform.slice(-LIVE_LEVEL_SAMPLES)
const uploadData = {
correlationId,
durationMs,
mimeType,
note: finalMetadata.note,
pinned: finalMetadata.pinned,
title: finalMetadata.title,
waveform,
}
postRecorderState('uploading')
if (import.meta.env.DEV) {
const response = await nuiCall<MemoDto>('memos:devCapture', {
...uploadData,
audioDataUrl: await blobDataUrl(blob),
})
if (
generation !== recordingGeneration ||
currentCorrelationId !== correlationId
) {
return
}
pendingMemos.delete(correlationId)
currentCorrelationId = ''
if (!response.success || !isMemoDto(response.data)) {
postRecorderState('error', response.error ?? 'invalid_memo')
return
}
memos.upsert(response.data)
postRecorderState('idle')
window.postMessage({ data: response.data, type: 'memo:saved' }, '*')
return
}
pendingMemos.set(correlationId, {
blob,
fileName: `${correlationId}.webm`,
})
const response = await nuiCall('memos:requestUpload', uploadData)
if (
generation !== recordingGeneration ||
currentCorrelationId !== correlationId
) {
return
}
if (!response.success) {
pendingMemos.delete(correlationId)
currentCorrelationId = ''
postRecorderState('error', response.error ?? 'request_failed')
}
} catch (error) {
if (generation !== recordingGeneration) return
console.error('[Memos] Could not finalize audio recording.', error)
if (currentCorrelationId) pendingMemos.delete(currentCorrelationId)
currentCorrelationId = ''
cleanupRecorder(true)
resetRecordingData()
postRecorderState('error', 'recording_failed')
}
}
async function cancelPendingMemo(
correlationId: string,
pending: PendingMemo,
): Promise<void> {
pending.abortController?.abort()
pendingMemos.delete(correlationId)
await nuiCall('memos:cancelUpload', {
correlationId,
...(pending.requestId ? { requestId: pending.requestId } : {}),
})
}
function cancelRecording(): void {
recordingGeneration += 1
cleanupRecorder(true)
resetRecordingData()
for (const [correlationId, pending] of pendingMemos) {
void cancelPendingMemo(correlationId, pending)
}
pendingMemos.clear()
currentCorrelationId = ''
postRecorderState('idle')
}
async function failUpload(requestId: string, error: string): Promise<void> {
await nuiCall('memos:failUpload', { error, requestId })
}
async function uploadReady(ready: MemoUploadReady): Promise<void> {
const pending = pendingMemos.get(ready.correlationId)
if (!pending) {
await nuiCall('memos:cancelUpload', {
correlationId: ready.correlationId,
requestId: ready.requestId,
})
return
}
pending.requestId = ready.requestId
const form = new FormData()
form.append('file', pending.blob, pending.fileName)
form.append(
'metadata',
JSON.stringify({
captureToken: ready.captureToken,
purpose: 'memo',
source: 'sky_phone',
}),
)
const controller = new AbortController()
pending.abortController = controller
const timeout = window.setTimeout(
() => controller.abort(),
ready.uploadTimeoutMs ?? 25_000,
)
try {
const response = await fetch(ready.presignedUrl, {
body: form,
method: 'POST',
signal: controller.signal,
})
const body = (await response.json()) as {
data?: { id?: string; url?: string }
id?: string
url?: string
}
const uploaded = body.data ?? body
if (!response.ok || !uploaded.id || !uploaded.url) {
throw new Error('upload_failed')
}
const complete = await nuiCall('memos:completeUpload', {
remoteId: uploaded.id,
requestId: ready.requestId,
url: uploaded.url,
})
if (!complete.success) throw new Error('upload_failed')
} catch (error) {
if (controller.signal.aborted && !pendingMemos.has(ready.correlationId)) {
return
}
const errorCode =
error instanceof DOMException && error.name === 'AbortError'
? 'upload_timeout'
: 'upload_failed'
console.error('[Memos] Could not upload the audio recording.', error)
pendingMemos.delete(ready.correlationId)
await failUpload(ready.requestId, errorCode)
currentCorrelationId = ''
postRecorderState('error', errorCode)
} finally {
window.clearTimeout(timeout)
}
}
function resultMemo(data: Record<string, unknown>): MemoDto | null {
const candidate = data.memo ?? data.data ?? data
return isMemoDto(candidate) ? candidate : null
}
function uploadResult(data: Record<string, unknown>): void {
const result = data as Partial<MemoUploadResult>
const correlationId =
typeof result.correlationId === 'string' ? result.correlationId : ''
if (!correlationId) return
const pending = pendingMemos.get(correlationId)
pending?.abortController?.abort()
pendingMemos.delete(correlationId)
if (currentCorrelationId !== correlationId) return
currentCorrelationId = ''
if (result.success) {
const memo = resultMemo(data)
if (!memo) {
console.error('[Memos] Upload result did not contain a valid memo.')
postRecorderState('error', 'invalid_memo')
return
}
memos.upsert(memo)
postRecorderState('idle')
window.postMessage({ data: memo, type: 'memo:saved' }, '*')
void memos.load()
return
}
postRecorderState(
'error',
typeof result.error === 'string' ? result.error : 'upload_failed',
)
}
function onMessage(event: MessageEvent): void {
if (!isTrustedRootMessageSource(event.source, window)) return
const message = event.data as {
data?: Record<string, unknown>
type?: string
}
const data = message.data ?? {}
if (message.type === 'memo:recordStart') {
void startRecording(data)
} else if (message.type === 'memo:recordPause') {
pauseRecording()
} else if (message.type === 'memo:recordResume') {
resumeRecording()
} else if (message.type === 'memo:recordStop') {
void stopRecording(data)
} else if (message.type === 'memo:recordCancel') {
cancelRecording()
} else if (message.type === 'memos:uploadReady') {
void uploadReady(data as MemoUploadReady)
} else if (message.type === 'memos:uploadResult') {
uploadResult(data)
}
}
onMounted(() => window.addEventListener('message', onMessage))
watch(
() =>
[
phone.isOpen,
phone.device?.imei ?? null,
phone.deviceSessionToken,
] as const,
([isOpen, imei, sessionToken], previous) => {
const deviceSessionChanged =
previous !== undefined &&
previous[0] &&
(previous[1] !== imei || previous[2] !== sessionToken)
if (!isOpen || deviceSessionChanged) cancelRecording()
},
)
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
recordingGeneration += 1
cleanupRecorder(true)
for (const [correlationId, pending] of pendingMemos) {
void cancelPendingMemo(correlationId, pending)
}
pendingMemos.clear()
})
</script>
<template>
<span class="phone-memo-recorder" aria-hidden="true"></span>
</template>
<style scoped>
.phone-memo-recorder {
display: none;
}
</style>
+16
View File
@@ -1,4 +1,5 @@
import {
AudioLines,
Calculator,
Bomb,
Blocks,
@@ -48,6 +49,7 @@ import messagesIcon from '@/assets/img/app-icons/sms.webp'
import darkChatIcon from '@/assets/img/app-icons/darkchat.webp'
import notesIcon from '@/assets/img/app-icons/notes.webp'
import radioIcon from '@/assets/img/app-icons/radio.webp'
import memosIcon from '@/assets/img/app-icons/memos.webp'
import photosIcon from '@/assets/img/app-icons/gallery.webp'
import phoneIcon from '@/assets/img/app-icons/phone.webp'
import settingsIcon from '@/assets/img/app-icons/settings.svg'
@@ -393,6 +395,20 @@ export const PHONE_APPS = shallowReactive<PhoneAppDefinition[]>([
labelKey: 'Apps.notes.name',
route: '/apps/notes',
},
{
category: 'productivity',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MemosApp.vue')),
),
dockOrder: null,
gridOrder: 8,
icon: markRaw(AudioLines),
iconClass: '',
iconImage: memosIcon,
id: 'memos',
labelKey: 'Apps.memos.name',
route: '/apps/memos',
},
{
category: 'productivity',
component: markRaw(
+52
View File
@@ -0,0 +1,52 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { MemoDto, MemoUpdate } from '@/types/memos'
import { isMemoDto } from '@/types/memos'
import { nuiCall, type NuiResponse } from '@/utils/nui'
export const useMemosStore = defineStore('memos', () => {
const memos = ref<MemoDto[]>([])
const loading = ref(false)
function hydrate(items: MemoDto[]): void {
memos.value = Array.isArray(items) ? items.filter(isMemoDto) : []
}
function upsert(memo: MemoDto): void {
const index = memos.value.findIndex((item) => item.id === memo.id)
if (index >= 0) memos.value[index] = memo
else memos.value.unshift(memo)
memos.value.sort((left, right) => right.updatedAt - left.updatedAt)
}
async function load(): Promise<NuiResponse<MemoDto[]>> {
loading.value = true
try {
const response = await nuiCall<MemoDto[]>('memos:list')
if (response.success && Array.isArray(response.data)) hydrate(response.data)
return response
} finally {
loading.value = false
}
}
async function update(
id: string,
changes: MemoUpdate,
): Promise<NuiResponse<MemoDto>> {
const response = await nuiCall<MemoDto>('memos:update', { id, ...changes })
if (response.success && isMemoDto(response.data)) upsert(response.data)
return response
}
async function deleteMemo(id: string): Promise<NuiResponse<{ id: string }>> {
const response = await nuiCall<{ id: string }>('memos:delete', { id })
if (response.success) {
memos.value = memos.value.filter((memo) => memo.id !== id)
}
return response
}
return { deleteMemo, hydrate, load, loading, memos, update, upsert }
})
+67 -2
View File
@@ -33,6 +33,7 @@ export type PhoneOpenPayload = {
device?: PhoneDevice
lang?: string
locales?: LocaleTree
memos?: DeviceBootstrap['memos']
notes?: DeviceBootstrap['notes']
security?: DeviceSecurity
token?: string
@@ -3434,6 +3435,71 @@ const defaultLocales: LocaleTree = {
unpin: 'Unpin note',
deleteNote: 'Delete note',
},
memos: {
name: 'Memos',
memo: 'Voice Memo',
back: 'Back',
actions: 'Memo actions',
newMemo: 'New Recording',
newMemoWithDate: 'New Recording · {date}',
searchPlaceholder: 'Search Recordings',
emptyTitle: 'No Recordings',
emptyBody: 'Tap the record button to capture your first voice memo.',
noResults: 'No Results',
noResultsBody: 'Try searching for a different title or note.',
recording: 'Recording',
paused: 'Paused',
preparing: 'Preparing microphone…',
saving: 'Saving Recording…',
stop: 'Stop',
pause: 'Pause',
resume: 'Resume',
cancel: 'Cancel',
done: 'Done',
play: 'Play recording',
pausePlayback: 'Pause recording',
skipBack: 'Back 15 seconds',
skipForward: 'Forward 15 seconds',
playbackSpeed: 'Playback Speed',
title: 'Title',
titlePlaceholder: 'Recording title',
note: 'Note',
notePlaceholder: 'Add details…',
delete: 'Delete memo',
deleteTitle: 'Delete Voice Memo?',
deleteBody: 'This recording will be permanently deleted.',
discardTitle: 'Discard Recording?',
discardBody: 'The current recording will not be saved.',
discard: 'Discard',
keepRecording: 'Keep Recording',
deleted: 'Memo deleted.',
microphoneUnavailable: 'The microphone is unavailable.',
recordingTooLarge: 'The recording is too large to save.',
maximumDuration: 'The maximum recording duration was reached.',
errors: {
conflict: 'This memo changed on another device. It has been reloaded.',
invalid_memo: 'The voice memo contains invalid data.',
invalid_request: 'The memo request is invalid.',
invalid_upload: 'The uploaded recording could not be verified.',
invalid_upload_token: 'The recording upload could not be verified.',
memo_limit: 'The limit of 100 voice memos has been reached.',
memo_not_found: 'This voice memo no longer exists.',
media_provider_failed: 'The voice memo upload service is unavailable.',
media_provider_rate_limited:
'The voice memo upload service is busy. Try again shortly.',
media_provider_unauthorized:
'The configured FiveManage API key was rejected.',
missing_config: 'Voice memo uploads are not configured.',
operation_in_progress: 'Finish the current recording first.',
owner_changed: 'The active phone changed while saving the memo.',
rate_limited: 'Too many memo changes. Try again shortly.',
recording_failed: 'The recording could not be captured.',
request_timeout: 'The memo request timed out.',
upload_failed: 'The recording could not be uploaded.',
upload_timeout: 'The recording upload timed out.',
request_failed: 'Memos are temporarily unavailable.',
},
},
photos: {
name: 'Gallery',
count: '{count} items',
@@ -3457,8 +3523,7 @@ const defaultLocales: LocaleTree = {
title: 'Import',
chooseSource: 'Choose a website',
linkTitle: 'Import from Link',
linkBody:
'Paste a direct link to an image or video from this website.',
linkBody: 'Paste a direct link to an image or video from this website.',
linkLabel: 'Image or video link',
linkPlaceholder: 'https://...',
linkCompleted: 'Media imported.',
+1
View File
@@ -16,6 +16,7 @@ export type BuiltinPhoneAppId =
| 'mail'
| 'map'
| 'notes'
| 'memos'
| 'radio'
| 'music'
| 'photos'
+2
View File
@@ -1,4 +1,5 @@
import type { Note } from '@/utils/notes'
import type { MemoDto } from '@/types/memos'
import type { PhoneSim } from '@/types/phone'
export type DeviceDataEntry<T = unknown> = {
@@ -42,6 +43,7 @@ export type IfruitAccount = {
export type DeviceBootstrap = {
account: IfruitAccount | null
device: PhoneDevice
memos: MemoDto[]
notes: Note[]
security: DeviceSecurity
token: string
+71
View File
@@ -0,0 +1,71 @@
export type MemoDto = {
id: string
title: string
note: string
url: string
mimeType: string
durationMs: number
waveform: number[]
pinned: boolean
revision: number
createdAt: number
updatedAt: number
}
export type MemoUpdate = Pick<MemoDto, 'title' | 'note' | 'pinned' | 'revision'>
export type MemoRecorderStateName =
| 'idle'
| 'starting'
| 'recording'
| 'paused'
| 'stopping'
| 'uploading'
| 'error'
export type MemoRecorderState = {
state: MemoRecorderStateName
elapsedMs: number
levels: number[]
error?: string
}
export type MemoRecordingMetadata = {
title: string
note: string
pinned: boolean
}
export type MemoUploadReady = {
requestId: string
correlationId: string
captureToken: string
presignedUrl: string
uploadTimeoutMs?: number
}
export type MemoUploadResult = {
correlationId: string
success: boolean
error?: string
memo?: MemoDto
}
export function isMemoDto(value: unknown): value is MemoDto {
if (!value || typeof value !== 'object') return false
const memo = value as Partial<MemoDto>
return (
typeof memo.id === 'string' &&
typeof memo.title === 'string' &&
typeof memo.note === 'string' &&
typeof memo.url === 'string' &&
typeof memo.mimeType === 'string' &&
typeof memo.durationMs === 'number' &&
Array.isArray(memo.waveform) &&
memo.waveform.every((sample) => typeof sample === 'number') &&
typeof memo.pinned === 'boolean' &&
typeof memo.revision === 'number' &&
typeof memo.createdAt === 'number' &&
typeof memo.updatedAt === 'number'
)
}
+2 -2
View File
@@ -77,7 +77,7 @@ describe('preferences', () => {
expect(value.settings.appearanceMode).toBe('automatic')
expect(value.settings.frame).toBe('black')
expect(value.settings.graphicsMode).toBe('performance')
expect(value.settings.graphicsMode).toBe('ultimate')
expect(value.settings.notificationVolume).toBe(0)
expect(value.settings.notificationDurationSeconds).toBe(30)
expect(value.settings.phoneScale).toBe(150)
@@ -127,7 +127,7 @@ describe('preferences', () => {
bluetoothEnabled: true,
cellularEnabled: true,
focusMode: false,
graphicsMode: 'performance',
graphicsMode: 'ultimate',
screenBrightness: 100,
wifiEnabled: true,
})
+2 -1
View File
@@ -105,6 +105,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
mail: { enabled: true, sounds: true },
map: { enabled: true, sounds: true },
notes: { enabled: true, sounds: true },
memos: { enabled: true, sounds: true },
radio: { enabled: true, sounds: true },
music: { enabled: true, sounds: true },
photos: { enabled: true, sounds: true },
@@ -119,7 +120,7 @@ export const DEFAULT_PHONE_PREFERENCES: PhonePreferencesV1 = {
cellularEnabled: true,
focusMode: false,
frame: 'black',
graphicsMode: 'performance',
graphicsMode: 'ultimate',
notificationSound: 'chime',
notificationDurationSeconds: 10,
notificationVolume: 70,
File diff suppressed because it is too large Load Diff
+225 -2
View File
@@ -1,3 +1,5 @@
const { randomUUID } = require('node:crypto')
const cors = require('cors')
const express = require('express')
@@ -5,7 +7,7 @@ const app = express()
const port = Number(process.argv[2]) || 3001
app.use(cors())
app.use(express.json())
app.use(express.json({ limit: '3mb' }))
const lifecycleEndpoints = new Set([
'camera:setActive',
@@ -38,6 +40,14 @@ function unixTime(offsetSeconds = 0) {
return Math.floor(Date.now() / 1000) + offsetSeconds
}
function memoWaveform(phase = 0) {
return Array.from({ length: 48 }, (_, index) =>
Number(
(0.12 + Math.abs(Math.sin((index + phase) * 0.67)) * 0.76).toFixed(3),
),
)
}
let authenticated = true
let draft = null
const radioData = {
@@ -1956,6 +1966,53 @@ let mockNotes = [
updatedAt: Date.now() - 172_800_000,
},
]
let mockMemos = [
{
createdAt: Date.now() - 25 * 60_000,
durationMs: 42_100,
id: '4b918e0e-840e-4f35-99d7-c93b047bc3f7',
mediaId: 9101,
mimeType: 'audio/ogg',
note: 'Patrol route via Mission Row, Pillbox Hill and the Vespucci canals.',
pinned: true,
revision: 2,
sizeBytes: 132_840,
title: 'Night shift briefing',
updatedAt: Date.now() - 18 * 60_000,
url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga',
waveform: memoWaveform(1),
},
{
createdAt: Date.now() - 3 * 60 * 60_000,
durationMs: 18_600,
id: '7df6287a-a3f1-49cc-a722-a950fc9a5dd2',
mediaId: 9102,
mimeType: 'audio/ogg',
note: 'Check the paint, engine sound and service history before making an offer.',
pinned: false,
revision: 1,
sizeBytes: 61_920,
title: 'Sultan RS inspection',
updatedAt: Date.now() - 3 * 60 * 60_000,
url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga',
waveform: memoWaveform(5),
},
{
createdAt: Date.now() - 26 * 60 * 60_000,
durationMs: 7_400,
id: '0b2a0d0d-3eb7-4623-873e-829a96a9d525',
mediaId: 9103,
mimeType: 'audio/ogg',
note: 'Repair kit, flashlight and two bottles of water.',
pinned: false,
revision: 1,
sizeBytes: 26_480,
title: 'Supply reminder',
updatedAt: Date.now() - 26 * 60 * 60_000,
url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga',
waveform: memoWaveform(9),
},
]
let calendarEvents = [
{
endsAt: calendarTime(0, 10),
@@ -3479,8 +3536,16 @@ function companyWorkContext(testScenario = '') {
}
app.post('/api/:endpoint', async (request, response, next) => {
console.log(`[NUI] ${request.params.endpoint}`, request.body)
const endpoint = request.params.endpoint
console.log(
`[NUI] ${endpoint}`,
endpoint === 'memos:devCapture'
? {
...request.body,
audioDataUrl: `<${String(request.body.audioDataUrl ?? '').length} characters>`,
}
: request.body,
)
if (endpoint === 'music:bootstrap') {
response.json({ success: true, data: musicBootstrap() })
return
@@ -7715,6 +7780,7 @@ app.post('/api/:endpoint', (request, response) => {
name: 'Personal iFruit Phone',
sim: mockSim,
},
memos: mockMemos,
notes: mockNotes,
security: mockSecurity,
token: 'development',
@@ -8170,6 +8236,7 @@ app.post('/api/:endpoint', (request, response) => {
if (endpoint === 'device:factory-reset') {
authenticated = false
linkedAccount = null
mockMemos = []
mockNotes = []
mockMedia = []
calendarEvents = []
@@ -8941,6 +9008,162 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true })
return
}
if (endpoint === 'memos:list') {
response.json({ success: true, data: mockMemos })
return
}
if (endpoint === 'memos:devCapture') {
const correlationId =
typeof request.body.correlationId === 'string'
? request.body.correlationId
: ''
const title =
typeof request.body.title === 'string' ? request.body.title.trim() : ''
const note =
request.body.note === undefined
? ''
: typeof request.body.note === 'string'
? request.body.note.trim()
: null
const durationMs = Math.floor(Number(request.body.durationMs))
const mimeType =
request.body.mimeType === 'audio/ogg'
? 'audio/ogg'
: ['audio/webm', 'audio/webm;codecs=opus'].includes(
request.body.mimeType,
)
? 'audio/webm'
: null
const waveform = Array.isArray(request.body.waveform)
? request.body.waveform.map(Number)
: []
const audioDataUrl =
typeof request.body.audioDataUrl === 'string'
? request.body.audioDataUrl
: ''
const separator = audioDataUrl.indexOf(';base64,')
const audioMime =
separator > 5
? audioDataUrl.slice(5, separator).split(';', 1)[0].toLowerCase()
: ''
const encodedAudio = separator > 0 ? audioDataUrl.slice(separator + 8) : ''
const validAudio =
Boolean(mimeType) &&
audioMime === mimeType &&
encodedAudio.length > 0 &&
encodedAudio.length % 4 === 0 &&
/^[A-Za-z0-9+/]+={0,2}$/.test(encodedAudio)
const sizeBytes = validAudio
? Buffer.from(encodedAudio, 'base64').byteLength
: 0
if (
!correlationId ||
correlationId.length > 80 ||
!title ||
title.length > 120 ||
note === null ||
note.length > 2000 ||
!Number.isFinite(durationMs) ||
durationMs < 300 ||
durationMs > 300_000 ||
typeof request.body.pinned !== 'boolean' ||
waveform.length < 8 ||
waveform.length > 96 ||
waveform.some(
(sample) => !Number.isFinite(sample) || sample < 0 || sample > 1,
) ||
!validAudio ||
sizeBytes < 1 ||
sizeBytes > 2 * 1024 * 1024 ||
mockMemos.length >= 100
) {
response.json({ success: false, error: 'invalid_memo' })
return
}
const now = Date.now()
const memo = {
createdAt: now,
durationMs,
id: randomUUID(),
mediaId:
Math.max(0, ...mockMemos.map((item) => Number(item.mediaId) || 0)) + 1,
mimeType,
note,
pinned: request.body.pinned,
revision: 1,
sizeBytes,
title,
updatedAt: now,
url: audioDataUrl,
waveform,
}
mockMemos.unshift(memo)
response.json({ success: true, data: memo })
return
}
if (endpoint === 'memos:update') {
const id = typeof request.body.id === 'string' ? request.body.id : ''
const title =
typeof request.body.title === 'string' ? request.body.title.trim() : ''
const note =
request.body.note === undefined
? ''
: typeof request.body.note === 'string'
? request.body.note.trim()
: null
const revision = Math.floor(Number(request.body.revision) || 0)
if (
id.length !== 36 ||
!title ||
title.length > 120 ||
note === null ||
note.length > 2000 ||
typeof request.body.pinned !== 'boolean' ||
revision < 1
) {
response.json({ success: false, error: 'invalid_memo' })
return
}
const index = mockMemos.findIndex(
(memo) => memo.id === id && memo.revision === revision,
)
if (index < 0) {
response.json({ success: false, error: 'conflict', data: mockMemos })
return
}
const memo = {
...mockMemos[index],
note,
pinned: request.body.pinned,
revision: revision + 1,
title,
updatedAt: Date.now(),
}
mockMemos[index] = memo
mockMemos.sort(
(left, right) =>
Number(right.pinned) - Number(left.pinned) ||
right.updatedAt - left.updatedAt ||
right.id.localeCompare(left.id),
)
response.json({ success: true, data: memo })
return
}
if (endpoint === 'memos:delete') {
const id = typeof request.body.id === 'string' ? request.body.id : ''
if (id.length !== 36) {
response.json({ success: false, error: 'invalid_memo' })
return
}
const index = mockMemos.findIndex((memo) => memo.id === id)
if (index < 0) {
response.json({ success: false, error: 'memo_not_found' })
return
}
mockMemos.splice(index, 1)
response.json({ success: true, data: { id } })
return
}
if (endpoint === 'notes:list') {
response.json({ success: true, data: mockNotes })
return
+84
View File
@@ -46,6 +46,7 @@ const browserDataRequests = [
['marketplace:profile', {}],
['messages:conversations', {}],
['messages:gifs', { query: 'party' }],
['memos:list', {}],
['music:bootstrap', {}],
['notes:list', {}],
['pages:list', {}],
@@ -82,6 +83,89 @@ async function expectSuccess(baseUrl, endpoint, body = {}, data = false) {
}
async function verifyStatefulActions(baseUrl) {
const memoBootstrap = await expectSuccess(
baseUrl,
'development:bootstrap',
{},
true,
)
assert(
Array.isArray(memoBootstrap.memos) && memoBootstrap.memos.length >= 3,
'development:bootstrap did not include realistic memo data',
)
assert(
memoBootstrap.memos.every(
(memo) =>
typeof memo.url === 'string' &&
memo.url.length > 0 &&
Array.isArray(memo.waveform) &&
memo.waveform.length >= 8,
),
'development:bootstrap returned an invalid memo response shape',
)
let memos = await expectSuccess(baseUrl, 'memos:list', {}, true)
const capturedMemo = await expectSuccess(
baseUrl,
'memos:devCapture',
{
audioDataUrl: 'data:audio/webm;base64,T2dnUw==',
correlationId: 'browser-smoke-memo',
durationMs: 1_250,
mimeType: 'audio/webm;codecs=opus',
note: 'Recorded in the browser preview.',
pinned: false,
title: 'Browser recording',
waveform: Array(16).fill(0.35),
},
true,
)
assert.equal(capturedMemo.title, 'Browser recording')
assert.equal(capturedMemo.mimeType, 'audio/webm')
assert.equal(capturedMemo.sizeBytes, 4)
assert.match(capturedMemo.id, /^[0-9a-f-]{36}$/)
memos = await expectSuccess(baseUrl, 'memos:list', {}, true)
assert(
memos.some((item) => item.id === capturedMemo.id),
'memos:devCapture did not persist',
)
await expectSuccess(baseUrl, 'memos:delete', { id: capturedMemo.id }, true)
memos = await expectSuccess(baseUrl, 'memos:list', {}, true)
const memo = memos.find((item) => !item.pinned)
assert(memo, 'memos:list did not return an editable memo')
const updatedMemo = await expectSuccess(
baseUrl,
'memos:update',
{
id: memo.id,
note: 'Updated by the browser mock smoke test.',
pinned: true,
revision: memo.revision,
title: 'Updated browser memo',
},
true,
)
assert.equal(updatedMemo.id, memo.id)
assert.equal(updatedMemo.title, 'Updated browser memo')
assert.equal(updatedMemo.note, 'Updated by the browser mock smoke test.')
assert.equal(updatedMemo.pinned, true)
assert.equal(updatedMemo.revision, memo.revision + 1)
assert(Array.isArray(updatedMemo.waveform) && updatedMemo.waveform.length > 0)
const deletedMemo = await expectSuccess(
baseUrl,
'memos:delete',
{ id: updatedMemo.id },
true,
)
assert.deepEqual(deletedMemo, { id: updatedMemo.id })
memos = await expectSuccess(baseUrl, 'memos:list', {}, true)
assert(
!memos.some((item) => item.id === updatedMemo.id),
'memos:delete did not persist',
)
const noteId = `browser-note-${Date.now()}`
let notes = await expectSuccess(
baseUrl,
+3 -4
View File
@@ -3,9 +3,8 @@ import { fileURLToPath, URL } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig(({ command }) => ({
export default defineConfig({
base: './',
build: {
assetsDir: 'assets',
@@ -23,7 +22,7 @@ export default defineConfig(({ command }) => ({
},
},
},
plugins: [command === 'serve' && vueDevTools(), tailwindcss(), vue()],
plugins: [tailwindcss(), vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
@@ -38,4 +37,4 @@ export default defineConfig(({ command }) => ({
// Open the UI through localhost so embeds still receive that page origin.
host: '127.0.0.1',
},
}))
})
+14
View File
@@ -193,6 +193,20 @@ Config.Messages = {
DeleteBatchSize = 20,
}
Config.Memos = {
MaximumCount = 100,
MaximumDurationMs = 300000,
MaximumBytes = 2 * 1024 * 1024,
MaximumWaveformSamples = 96,
TitleMaxLength = 120,
NoteMaxLength = 2000,
UploadsPerMinute = 10,
WritesPerMinute = 60,
ListsPerMinute = 60,
MaximumPendingUploads = 1,
UploadSessionTimeoutMs = 60000,
}
Config.EasyShare = {
Enabled = true,
DefaultVisibility = "everyone", -- everyone, contacts or hidden
+36
View File
@@ -1528,6 +1528,42 @@ Locales["en"] = {
noResultsBody = "Try searching for a different word or phrase.", pin = "Pin note", unpin = "Unpin note",
deleteNote = "Delete note",
},
memos = {
name = "Memos", memo = "Voice Memo", back = "Back", actions = "Memo actions",
newMemo = "New Recording", newMemoWithDate = "New Recording · {date}",
searchPlaceholder = "Search Recordings", emptyTitle = "No Recordings",
emptyBody = "Tap the record button to capture your first voice memo.", noResults = "No Results",
noResultsBody = "Try searching for a different title or note.", recording = "Recording", paused = "Paused",
preparing = "Preparing microphone…", saving = "Saving Recording…", stop = "Stop", pause = "Pause",
resume = "Resume", cancel = "Cancel", done = "Done", play = "Play recording",
pausePlayback = "Pause recording", skipBack = "Back 15 seconds", skipForward = "Forward 15 seconds",
playbackSpeed = "Playback Speed", title = "Title", titlePlaceholder = "Recording title",
note = "Note", notePlaceholder = "Add details…", delete = "Delete memo", deleteTitle = "Delete Voice Memo?",
deleteBody = "This recording will be permanently deleted.", discardTitle = "Discard Recording?",
discardBody = "The current recording will not be saved.", discard = "Discard",
keepRecording = "Keep Recording", deleted = "Memo deleted.",
microphoneUnavailable = "The microphone is unavailable.",
recordingTooLarge = "The recording is too large to save.",
maximumDuration = "The maximum recording duration was reached.",
errors = {
conflict = "This memo changed on another device. It has been reloaded.",
invalid_memo = "The voice memo contains invalid data.", invalid_request = "The memo request is invalid.",
invalid_upload = "The uploaded recording could not be verified.",
invalid_upload_token = "The recording upload could not be verified.",
memo_limit = "The limit of 100 voice memos has been reached.",
memo_not_found = "This voice memo no longer exists.",
media_provider_failed = "The voice memo upload service is unavailable.",
media_provider_rate_limited = "The voice memo upload service is busy. Try again shortly.",
media_provider_unauthorized = "The configured FiveManage API key was rejected.",
missing_config = "Voice memo uploads are not configured.",
operation_in_progress = "Finish the current recording first.",
owner_changed = "The active phone changed while saving the memo.",
rate_limited = "Too many memo changes. Try again shortly.",
recording_failed = "The recording could not be captured.",
request_timeout = "The memo request timed out.", upload_failed = "The recording could not be uploaded.",
upload_timeout = "The recording upload timed out.", request_failed = "Memos are temporarily unavailable.",
},
},
easyShare = {
name = "EasyShare", incoming = "Incoming Share", recentChats = "Contacts and Chats",
destinations = "Share destinations", newMessage = "New Message", sentToChat = "Sent to chat.",
+2 -2
View File
@@ -1,11 +1,11 @@
Config.Media = {
GiphyApiKeyConvar = "sky_phone_giphy_api_key",
GiphyApiKey = "", -- Server-only: paste the GIPHY API key here.
GifPageSize = 24,
GifRating = "pg-13",
UrlMaxLength = 2048,
AllowedGifHosts = { "giphy.com" },
FiveManage = {
ApiKeyConvar = "sky_phone_fivemanage_api_key",
ApiKey = "", -- Server-only: paste a newly generated FiveManage V3 Media API token here.
BaseUrl = "https://api.fivemanage.com/api/v3/file",
RequestTimeoutMs = 10000,
UploadTimeoutMs = 25000,
+1
View File
@@ -77,6 +77,7 @@ server_scripts {
'source/server/media.lua',
'source/server/weazel_news.lua',
'source/server/messages.lua',
'source/server/memos.lua',
'source/server/easyshare.lua',
'source/server/darkchat.lua',
'source/server/flare.lua',
+44
View File
@@ -453,6 +453,42 @@ RegisterNUICallback("gallery:delete", function(data, cb)
cb({ success = true })
end)
RegisterNUICallback("memos:requestUpload", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
TriggerServerEvent("sky_phone:memos:request-upload", data)
cb({ success = true })
end)
RegisterNUICallback("memos:completeUpload", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
TriggerServerEvent("sky_phone:memos:complete-upload", data)
cb({ success = true })
end)
RegisterNUICallback("memos:cancelUpload", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
TriggerServerEvent("sky_phone:memos:cancel-upload", data)
cb({ success = true })
end)
RegisterNUICallback("memos:failUpload", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
return
end
TriggerServerEvent("sky_phone:memos:fail-upload", data)
cb({ success = true })
end)
RegisterNetEvent("sky_phone:media:upload-ready", function(data)
SendNUIMessage({ type = "media:uploadReady", data = data })
end)
@@ -465,6 +501,14 @@ RegisterNetEvent("sky_phone:media:delete-result", function(data)
SendNUIMessage({ type = "media:deleteResult", data = data })
end)
RegisterNetEvent("sky_phone:memos:upload-ready", function(data)
SendNUIMessage({ type = "memos:uploadReady", data = data })
end)
RegisterNetEvent("sky_phone:memos:upload-result", function(data)
SendNUIMessage({ type = "memos:uploadResult", data = data })
end)
AddEventHandler("sky_phone:nuiClosed", function()
set_flash_enabled(false)
set_camera_active(false)
+3
View File
@@ -236,6 +236,9 @@ local server_callbacks = {
"messages:media",
"messages:delete",
"messages:gifs",
"memos:list",
"memos:update",
"memos:delete",
"easyshare:bootstrap",
"easyshare:own-contact",
"easyshare:set-visibility",
+35 -1
View File
@@ -376,7 +376,7 @@ local schema = {
},
{ name = "url", type = "TEXT NOT NULL" },
{ name = "remote_id", type = "VARCHAR(128) NOT NULL" },
{ name = "media_type", type = "ENUM('photo', 'video') NOT NULL" },
{ name = "media_type", type = "ENUM('photo', 'video', 'audio') NOT NULL" },
{ name = "mime_type", type = "VARCHAR(120) NULL" },
{ name = "origin", type = "ENUM('phone_upload', 'website_import') NOT NULL DEFAULT 'phone_upload'" },
{
@@ -405,6 +405,36 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_voice_memos",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "media_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "title", type = "VARCHAR(120) NOT NULL" },
{ name = "note", type = "VARCHAR(2000) NOT NULL DEFAULT ''" },
{ name = "duration_ms", type = "INT UNSIGNED NOT NULL" },
{ name = "size_bytes", type = "INT UNSIGNED NOT NULL" },
{ name = "waveform", type = "TEXT NOT NULL" },
{ name = "pinned", type = "TINYINT(1) NOT NULL DEFAULT 0" },
{ name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{
name = "updated_at",
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
},
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_voice_memos_media", columns = "(`media_id`)" },
},
indexes = {
{ name = "idx_sky_phone_voice_memos_list", columns = "(`pinned`, `updated_at`, `id`)" },
},
foreignKeys = {
{ column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_contacts",
columns = {
@@ -2568,6 +2598,10 @@ Bridge.Database.Query([[
ALTER TABLE `sky_phone_darkchat_messages`
MODIFY COLUMN `message_type` ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'share', 'system') NOT NULL DEFAULT 'text'
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_media`
MODIFY COLUMN `media_type` ENUM('photo', 'video', 'audio') NOT NULL
]], {})
Bridge.Database.Query([[
UPDATE `sky_phone_media`
SET `mime_type` = CASE
+112 -22
View File
@@ -5,6 +5,10 @@ SkyPhoneMediaImport.Initialize()
local pending_uploads = {}
local pending_deletes = {}
local allowed_remote_mimes = {
audio = {
["audio/ogg"] = true,
["audio/webm"] = true,
},
photo = {
["image/jpeg"] = true,
["image/png"] = true,
@@ -21,11 +25,11 @@ local function media_config()
end
local function media_api_key()
local convar = media_config().ApiKeyConvar
if type(convar) ~= "string" or convar == "" then
local api_key = media_config().ApiKey
if type(api_key) ~= "string" then
return ""
end
return GetConvar(convar, "")
return api_key:match("^%s*(.-)%s*$")
end
local function api_configured()
@@ -35,13 +39,14 @@ end
local function http_request(url, method, body, headers, timeout_ms)
local request = promise.new()
local settled = false
PerformHttpRequest(url, function(status, response_body, response_headers)
PerformHttpRequest(url, function(status, response_body, response_headers, error_data)
if settled then
return
end
settled = true
request:resolve({
body = response_body,
error = error_data,
headers = response_headers,
status = status,
})
@@ -56,6 +61,23 @@ local function http_request(url, method, body, headers, timeout_ms)
return Citizen.Await(request)
end
local function response_error_message(response)
if type(response) ~= "table" then
return "invalid response"
end
if type(response.error) == "string" and response.error ~= "" then
return response.error:sub(1, 240)
end
local success, payload = pcall(json.decode, response.body or "")
if success and type(payload) == "table" then
local message = payload.error or payload.message
if type(message) == "string" and message ~= "" then
return message:sub(1, 240)
end
end
return "no provider error message"
end
local function decode_response(response)
if type(response) ~= "table" or type(response.status) ~= "number" then
return nil, "invalid_response"
@@ -75,6 +97,11 @@ end
local function request_presigned_url()
if not api_configured() then
Bridge.Debug(
"error",
"[sky_phone] FiveManage presigned upload request failed: Config.Media.FiveManage.ApiKey is empty or invalid.",
{ always = true }
)
return nil, "missing_config"
end
local config = Config.Media.FiveManage
@@ -85,13 +112,43 @@ local function request_presigned_url()
{ ["Authorization"] = media_api_key() },
tonumber(config.RequestTimeoutMs) or 10000
)
if response.status == 401 or response.status == 403 then
Bridge.Debug(
"error",
"[sky_phone] FiveManage presigned upload request was rejected with HTTP %s. Check Config.Media.FiveManage.ApiKey and its file permissions.",
tostring(response.status),
{ always = true }
)
return nil, "media_provider_unauthorized"
end
if response.status == 429 then
Bridge.Debug(
"error",
"[sky_phone] FiveManage presigned upload request was rate limited with HTTP 429.",
{ always = true }
)
return nil, "media_provider_rate_limited"
end
local data, response_error = decode_response(response)
if not data then
return nil, response_error
Bridge.Debug(
"error",
"[sky_phone] FiveManage presigned upload request failed with HTTP %s (%s): %s",
tostring(response.status),
tostring(response_error),
response_error_message(response),
{ always = true }
)
return nil, response_error == "request_timeout" and "request_timeout" or "media_provider_failed"
end
local presigned_url = data.presignedUrl or data.presigned_url
if type(presigned_url) ~= "string" or presigned_url == "" then
return nil, "missing_presigned_url"
Bridge.Debug(
"error",
"[sky_phone] FiveManage presigned upload response did not contain a presignedUrl.",
{ always = true }
)
return nil, "media_provider_failed"
end
return presigned_url
end
@@ -279,34 +336,55 @@ local function verify_remote_upload(state, remote_id, uploaded_url)
if remote.url ~= uploaded_url and remote.originalUrl ~= uploaded_url then
return nil, "invalid_upload"
end
local verified_url = remote.url or uploaded_url
if type(verified_url) ~= "string" or #verified_url > Config.Media.UrlMaxLength
or not verified_url:match("^https://")
then
return nil, "invalid_upload"
end
local metadata = parse_metadata(remote.metadata)
if not metadata or metadata.captureToken ~= state.capture_token or metadata.source ~= "sky_phone" then
return nil, "invalid_upload_token"
end
if state.purpose and metadata.purpose ~= state.purpose then
return nil, "invalid_upload", true
end
local allowed_mimes = allowed_remote_mimes[state.media_type]
if not allowed_mimes then
return nil, "invalid_media_type", true
end
local remote_mime = tostring(remote.mimeType or ""):lower():match("^%s*([^;%s]+)") or ""
local remote_type = tostring(remote.type or ""):lower():match("^%s*([^;%s]+)") or ""
if remote_mime == "" and allowed_remote_mimes[state.media_type][remote_type] then
if remote_mime == "" and allowed_mimes[remote_type] then
remote_mime = remote_type
end
if remote_type == "" then
remote_type = remote_mime
end
if state.media_type == "photo" and remote_type ~= "" and not remote_type:find("image", 1, true) then
return nil, "invalid_media_type"
return nil, "invalid_media_type", true
end
if state.media_type == "video" and remote_type ~= "" and not remote_type:find("video", 1, true) then
return nil, "invalid_media_type"
return nil, "invalid_media_type", true
end
if remote_mime ~= "" and not allowed_remote_mimes[state.media_type][remote_mime] then
return nil, "invalid_media_type"
if state.media_type == "audio" and remote_type ~= "" and not remote_type:find("audio", 1, true) then
return nil, "invalid_media_type", true
end
local metadata = parse_metadata(remote.metadata)
if not metadata or metadata.captureToken ~= state.capture_token then
return nil, "invalid_upload_token"
if remote_mime ~= "" and not allowed_mimes[remote_mime] then
return nil, "invalid_media_type", true
end
return {
mime_type = allowed_remote_mimes[state.media_type][remote_mime] and remote_mime or state.mime_type,
mime_type = allowed_mimes[remote_mime] and remote_mime or state.mime_type,
remote_id = remote_id,
url = remote.url or uploaded_url,
}
size = tonumber(remote.size),
url = verified_url,
}, nil, true
end
SkyPhoneMedia.DeleteRemoteFile = delete_remote_file
SkyPhoneMedia.RequestPresignedUrl = request_presigned_url
SkyPhoneMedia.VerifyRemoteUpload = verify_remote_upload
local function expire_upload(request_id)
local state = pending_uploads[request_id]
if not state or state.completing then
@@ -329,6 +407,7 @@ Bridge.Callbacks.Register("sky_phone:gallery:list", function(source, data)
media_type = nil
end
local condition, params = owner_condition(owner)
condition = condition .. " AND `media_type` IN ('photo', 'video')"
if media_type then
condition = condition .. " AND `media_type` = ?"
params[#params + 1] = media_type
@@ -408,7 +487,8 @@ Bridge.Callbacks.Register("sky_phone:messages:gifs", function(source, data)
if #query > 60 or offset < 0 or offset > 500 then
return { success = false, error = "invalid_request" }
end
local api_key = GetConvar(Config.Media.GiphyApiKeyConvar, "")
local api_key = type(Config.Media.GiphyApiKey) == "string"
and Config.Media.GiphyApiKey:match("^%s*(.-)%s*$") or ""
if api_key == "" then
return { success = false, error = "gif_provider_unconfigured" }
end
@@ -556,9 +636,20 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
upload_result(src, state.correlation_id, false, error_response and error_response.error or "owner_changed")
return
end
local verified, verify_error = verify_remote_upload(state, data.remoteId, data.url)
local verified, verify_error, trusted_remote = verify_remote_upload(state, data.remoteId, data.url)
if not verified then
pending_uploads[request_id] = nil
if trusted_remote then
local deleted, delete_error = delete_remote_file(data.remoteId)
if not deleted then
Bridge.Debug(
"warn",
"[sky_phone] Could not remove rejected media upload %s: %s.",
tostring(data.remoteId),
tostring(delete_error)
)
end
end
upload_result(src, state.correlation_id, false, verify_error)
return
end
@@ -642,7 +733,7 @@ RegisterNetEvent("sky_phone:media:delete", function(data)
end
local rows = Bridge.Database.Query(([[
SELECT `id`, `remote_id`, `origin` FROM `sky_phone_media`
WHERE `id` = ? AND %s LIMIT 1
WHERE `id` = ? AND %s AND `media_type` IN ('photo', 'video') LIMIT 1
]]):format(condition), query_params)
local row = rows[1]
if not row then
@@ -718,7 +809,6 @@ AddEventHandler("playerDropped", function()
end)
if not api_configured() then
print(("^3[sky_phone] Camera uploads and FiveManage imports are disabled until the %s server convar is set.^7")
:format(tostring(media_config().ApiKeyConvar)))
print("^3[sky_phone] Camera uploads and FiveManage imports are disabled until Config.Media.FiveManage.ApiKey is set in config/media.lua.^7")
end
end)
@@ -1,9 +1,9 @@
local function api_key(website)
local convar = website.ApiKeyConvar or Config.Media.FiveManage.ApiKeyConvar
if type(convar) ~= "string" or convar == "" then
local configured_key = website.ApiKey or Config.Media.FiveManage.ApiKey
if type(configured_key) ~= "string" then
return ""
end
return GetConvar(convar, "")
return configured_key:match("^%s*(.-)%s*$")
end
local function provider_error(response, not_found_error)
+532
View File
@@ -0,0 +1,532 @@
Bridge.Database.AfterMigration("sky_phone", function()
SkyPhoneMemos = {}
local pending_uploads = {}
local pending_deletes = {}
local allowed_audio_mimes = {
["audio/ogg"] = "audio/ogg",
["audio/webm"] = "audio/webm",
["audio/webm;codecs=opus"] = "audio/webm",
}
local function affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function trim(value)
if type(value) ~= "string" then
return nil
end
return value:match("^%s*(.-)%s*$")
end
local function valid_uuid(value)
if type(value) ~= "string" or #value ~= 36
or value:sub(9, 9) ~= "-"
or value:sub(14, 14) ~= "-"
or value:sub(19, 19) ~= "-"
or value:sub(24, 24) ~= "-"
then
return false
end
local compact = value:gsub("-", "")
return #compact == 32 and compact:match("^%x+$") ~= nil
end
local function session_owner(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, error_response
end
local device = SkyPhone.LoadDevice(session.imei)
if not device then
return nil, { success = false, error = "device_not_found" }
end
return {
account_id = device.account_id and tonumber(device.account_id) or nil,
imei = session.imei,
}
end
local function owner_condition(owner, alias)
local prefix = alias and ("`%s`."):format(alias) or ""
if owner.account_id then
return prefix .. "`account_id` = ?", { owner.account_id }
end
return prefix .. "`account_id` IS NULL AND " .. prefix .. "`device_imei` = ?", { owner.imei }
end
local function owner_key(owner)
if owner.account_id then
return "account:" .. tostring(owner.account_id)
end
return "device:" .. owner.imei
end
local function append_params(target, values)
for _, value in ipairs(values) do
target[#target + 1] = value
end
end
local function memo_dto(row)
local waveform = json.decode(row.waveform)
if type(waveform) ~= "table" then
error(("[sky_phone] Voice memo %s has an invalid waveform payload."):format(tostring(row.id)))
end
return {
id = row.id,
title = row.title,
note = row.note,
url = row.url,
mimeType = row.mime_type,
durationMs = tonumber(row.duration_ms) or 0,
waveform = waveform,
pinned = tonumber(row.pinned) == 1,
revision = tonumber(row.revision) or 1,
createdAt = (tonumber(row.created_at_unix) or 0) * 1000,
updatedAt = (tonumber(row.updated_at_unix) or 0) * 1000,
}
end
local function list_memos(owner)
local condition, params = owner_condition(owner, "media")
params[#params + 1] = Config.Memos.MaximumCount
local rows = Bridge.Database.Query(([[
SELECT memo.`id`, memo.`title`, memo.`note`, memo.`duration_ms`,
memo.`waveform`, memo.`pinned`, memo.`revision`, media.`url`, media.`mime_type`,
UNIX_TIMESTAMP(memo.`created_at`) AS `created_at_unix`,
UNIX_TIMESTAMP(memo.`updated_at`) AS `updated_at_unix`
FROM `sky_phone_voice_memos` memo
INNER JOIN `sky_phone_media` media ON media.`id` = memo.`media_id`
WHERE %s AND media.`media_type` = 'audio'
ORDER BY memo.`pinned` DESC, memo.`updated_at` DESC, memo.`id` DESC
LIMIT ?
]]):format(condition), params)
local memos = {}
for index, row in ipairs(rows) do
memos[index] = memo_dto(row)
end
return memos
end
local function find_memo(memos, id)
for _, memo in ipairs(memos) do
if memo.id == id then
return memo
end
end
end
function SkyPhoneMemos.List(account_id, imei)
return list_memos({ account_id = account_id and tonumber(account_id) or nil, imei = imei })
end
local function normalize_waveform(value)
if type(value) ~= "table" or #value < 8 or #value > Config.Memos.MaximumWaveformSamples then
return nil
end
local waveform = {}
for index = 1, #value do
local sample = tonumber(value[index])
if not sample or sample ~= sample or sample == math.huge or sample == -math.huge
or sample < 0 or sample > 1
then
return nil
end
waveform[index] = math.floor(sample * 1000 + 0.5) / 1000
end
return waveform
end
local function validate_upload(data)
if type(data) ~= "table" or type(data.correlationId) ~= "string"
or #data.correlationId < 1 or #data.correlationId > 80
then
return nil
end
local title = trim(data.title)
local note = data.note == nil and "" or trim(data.note)
local title_length = title and utf8.len(title) or nil
local note_length = note and utf8.len(note) or nil
local duration_ms = tonumber(data.durationMs)
local normalized_mime = allowed_audio_mimes[data.mimeType]
local waveform = normalize_waveform(data.waveform)
if not title_length or title_length < 1 or title_length > Config.Memos.TitleMaxLength
or not note_length or note_length > Config.Memos.NoteMaxLength
or not duration_ms or duration_ms ~= duration_ms
or duration_ms == math.huge or duration_ms == -math.huge
or duration_ms < 300 or duration_ms > Config.Memos.MaximumDurationMs
or not normalized_mime or not waveform or type(data.pinned) ~= "boolean"
then
return nil
end
duration_ms = math.floor(duration_ms)
return {
correlation_id = data.correlationId,
duration_ms = duration_ms,
mime_type = normalized_mime,
note = note,
pinned = data.pinned,
title = title,
waveform = waveform,
}
end
local function upload_result(source, correlation_id, success, error_code, memo)
TriggerClientEvent("sky_phone:memos:upload-result", source, {
correlationId = correlation_id,
success = success,
error = error_code,
memo = memo,
})
end
local function discard_verified_upload(remote_id)
local deleted, delete_error = SkyPhoneMedia.DeleteRemoteFile(remote_id)
if not deleted then
Bridge.Debug(
"warn",
"[sky_phone] Could not remove rejected voice memo upload %s: %s.",
tostring(remote_id),
tostring(delete_error)
)
end
end
local function expire_upload(request_id)
local state = pending_uploads[request_id]
if not state or state.completing then
return
end
pending_uploads[request_id] = nil
upload_result(state.source, state.memo.correlation_id, false, "upload_timeout")
end
Bridge.Callbacks.Register("sky_phone:memos:list", function(source)
if not SkyPhone.AllowOperation(source, "memos_list", Config.Memos.ListsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local owner, error_response = session_owner(source)
if not owner then
return error_response
end
return { success = true, data = list_memos(owner) }
end)
Bridge.Callbacks.Register("sky_phone:memos:update", function(source, data)
if not SkyPhone.AllowOperation(source, "memos_write", Config.Memos.WritesPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local owner, error_response = session_owner(source)
if not owner then
return error_response
end
local title = type(data) == "table" and trim(data.title) or nil
local note = type(data) == "table" and (data.note == nil and "" or trim(data.note)) or nil
local title_length = title and utf8.len(title) or nil
local note_length = note and utf8.len(note) or nil
local revision = type(data) == "table" and tonumber(data.revision) or nil
if type(data) ~= "table" or not valid_uuid(data.id)
or not title_length or title_length < 1 or title_length > Config.Memos.TitleMaxLength
or not note_length or note_length > Config.Memos.NoteMaxLength
or type(data.pinned) ~= "boolean" or not revision or revision ~= revision
or revision == math.huge or revision == -math.huge or revision ~= math.floor(revision)
or revision < 1 or revision > 4294967295
then
return { success = false, error = "invalid_memo" }
end
local condition, owner_params = owner_condition(owner, "media")
local params = { title, note, data.pinned and 1 or 0, data.id, revision }
append_params(params, owner_params)
local result = Bridge.Database.Query(([[
UPDATE `sky_phone_voice_memos` memo
INNER JOIN `sky_phone_media` media ON media.`id` = memo.`media_id`
SET memo.`title` = ?, memo.`note` = ?, memo.`pinned` = ?, memo.`revision` = memo.`revision` + 1
WHERE memo.`id` = ? AND memo.`revision` = ? AND %s AND media.`media_type` = 'audio'
]]):format(condition), params)
if affected_rows(result) ~= 1 then
return { success = false, error = "conflict", data = list_memos(owner) }
end
return { success = true, data = find_memo(list_memos(owner), data.id) }
end)
Bridge.Callbacks.Register("sky_phone:memos:delete", function(source, data)
if not SkyPhone.AllowOperation(source, "memos_write", Config.Memos.WritesPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local owner, error_response = session_owner(source)
if not owner then
return error_response
end
if type(data) ~= "table" or not valid_uuid(data.id) then
return { success = false, error = "invalid_memo" }
end
local condition, owner_params = owner_condition(owner, "media")
local params = { data.id }
append_params(params, owner_params)
local rows = Bridge.Database.Query(([[
SELECT media.`id` AS `media_id`, media.`remote_id`
FROM `sky_phone_voice_memos` memo
INNER JOIN `sky_phone_media` media ON media.`id` = memo.`media_id`
WHERE memo.`id` = ? AND %s AND media.`media_type` = 'audio'
LIMIT 1
]]):format(condition), params)
local memo = rows[1]
if not memo then
return { success = false, error = "memo_not_found" }
end
if pending_deletes[memo.remote_id] then
return { success = false, error = "operation_in_progress" }
end
pending_deletes[memo.remote_id] = source
local references = Bridge.Database.Query(
"SELECT COUNT(*) AS `count` FROM `sky_phone_media` WHERE `remote_id` = ?",
{ memo.remote_id }
)
if (tonumber(references[1] and references[1].count) or 0) <= 1 then
local deleted, delete_error = SkyPhoneMedia.DeleteRemoteFile(memo.remote_id)
if not deleted then
pending_deletes[memo.remote_id] = nil
return { success = false, error = delete_error }
end
end
local result = Bridge.Database.Query(([[
DELETE media FROM `sky_phone_media` media
INNER JOIN `sky_phone_voice_memos` memo ON memo.`media_id` = media.`id`
WHERE memo.`id` = ? AND %s AND media.`media_type` = 'audio'
]]):format(condition), params)
pending_deletes[memo.remote_id] = nil
if affected_rows(result) ~= 1 then
error(("[sky_phone] Voice memo %s disappeared during deletion."):format(data.id))
end
return { success = true, data = { id = data.id } }
end)
RegisterNetEvent("sky_phone:memos:request-upload", function(data)
local src = source
if not SkyPhone.AllowOperation(src, "memos_upload", Config.Memos.UploadsPerMinute, 60) then
upload_result(src, type(data) == "table" and data.correlationId or nil, false, "rate_limited")
return
end
local memo = validate_upload(data)
if not memo then
upload_result(src, type(data) == "table" and data.correlationId or nil, false, "invalid_memo")
return
end
local owner, error_response = session_owner(src)
if not owner then
upload_result(src, memo.correlation_id, false, error_response.error)
return
end
local condition, params = owner_condition(owner, "media")
local counts = Bridge.Database.Query(([[
SELECT COUNT(*) AS `count`
FROM `sky_phone_voice_memos` memo
INNER JOIN `sky_phone_media` media ON media.`id` = memo.`media_id`
WHERE %s AND media.`media_type` = 'audio'
]]):format(condition), params)
if (tonumber(counts[1] and counts[1].count) or 0) >= Config.Memos.MaximumCount then
upload_result(src, memo.correlation_id, false, "memo_limit")
return
end
local pending_count = 0
local pending_owner_key = owner_key(owner)
for _, state in pairs(pending_uploads) do
if state.owner_key == pending_owner_key then
pending_count = pending_count + 1
end
end
if pending_count >= Config.Memos.MaximumPendingUploads then
upload_result(src, memo.correlation_id, false, "operation_in_progress")
return
end
local presigned_url, presigned_error = SkyPhoneMedia.RequestPresignedUrl()
if not presigned_url then
Bridge.Debug(
"error",
"[sky_phone] Voice memo upload could not start for source %s: %s.",
tostring(src),
tostring(presigned_error),
{ always = true }
)
upload_result(src, memo.correlation_id, false, presigned_error)
return
end
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {})
local request_id = ids[1] and ids[1].request_id
local capture_token = ids[1] and ids[1].capture_token
if type(request_id) ~= "string" or type(capture_token) ~= "string" then
error("[sky_phone] Database did not generate a voice memo upload token.")
end
pending_uploads[request_id] = {
capture_token = capture_token,
media_type = "audio",
mime_type = memo.mime_type,
memo = memo,
owner = owner,
owner_key = pending_owner_key,
purpose = "memo",
source = src,
}
SetTimeout(Config.Memos.UploadSessionTimeoutMs, function()
expire_upload(request_id)
end)
TriggerClientEvent("sky_phone:memos:upload-ready", src, {
requestId = request_id,
correlationId = memo.correlation_id,
captureToken = capture_token,
presignedUrl = presigned_url,
uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs,
})
end)
RegisterNetEvent("sky_phone:memos:complete-upload", function(data)
local src = source
local request_id = type(data) == "table" and data.requestId or nil
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if not state or state.source ~= src or state.completing then
return
end
state.completing = true
local owner, error_response = session_owner(src)
if not owner or owner.imei ~= state.owner.imei or owner.account_id ~= state.owner.account_id then
pending_uploads[request_id] = nil
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
if rejected or trusted_remote then
discard_verified_upload(data.remoteId)
end
upload_result(src, state.memo.correlation_id, false, error_response and error_response.error or "owner_changed")
return
end
local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
if not verified then
pending_uploads[request_id] = nil
if trusted_remote then
discard_verified_upload(data.remoteId)
end
upload_result(src, state.memo.correlation_id, false, verify_error)
return
end
local size_bytes = tonumber(verified.size)
if not size_bytes or size_bytes ~= size_bytes
or size_bytes == math.huge or size_bytes == -math.huge
or size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes
then
pending_uploads[request_id] = nil
discard_verified_upload(verified.remote_id)
upload_result(src, state.memo.correlation_id, false, "recording_too_large")
return
end
size_bytes = math.floor(size_bytes)
local condition, count_params = owner_condition(owner, "media")
local counts = Bridge.Database.Query(([[
SELECT COUNT(*) AS `count`
FROM `sky_phone_voice_memos` memo
INNER JOIN `sky_phone_media` media ON media.`id` = memo.`media_id`
WHERE %s AND media.`media_type` = 'audio'
]]):format(condition), count_params)
if (tonumber(counts[1] and counts[1].count) or 0) >= Config.Memos.MaximumCount then
pending_uploads[request_id] = nil
discard_verified_upload(verified.remote_id)
upload_result(src, state.memo.correlation_id, false, "memo_limit")
return
end
local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local memo_id = ids[1] and ids[1].id
if type(memo_id) ~= "string" then
error("[sky_phone] Database did not generate a voice memo id.")
end
local media_query
local media_params
if owner.account_id then
media_query = [[
INSERT INTO `sky_phone_media`
(`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`, `verified_at`)
VALUES (?, NULL, ?, ?, 'audio', ?, CURRENT_TIMESTAMP)
]]
media_params = { owner.account_id, verified.url, verified.remote_id, verified.mime_type }
else
media_query = [[
INSERT INTO `sky_phone_media`
(`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`, `verified_at`)
VALUES (NULL, ?, ?, ?, 'audio', ?, CURRENT_TIMESTAMP)
]]
media_params = { owner.imei, verified.url, verified.remote_id, verified.mime_type }
end
local transaction = {
{
query = media_query,
params = media_params,
},
{
query = [[
INSERT INTO `sky_phone_voice_memos`
(`id`, `media_id`, `title`, `note`, `duration_ms`, `size_bytes`, `waveform`, `pinned`)
SELECT ?, LAST_INSERT_ID(), ?, ?, ?, ?, ?, ?
]],
params = {
memo_id,
state.memo.title,
state.memo.note,
state.memo.duration_ms,
size_bytes,
json.encode(state.memo.waveform),
state.memo.pinned and 1 or 0,
},
},
}
if not Bridge.Database.Transaction(transaction) then
pending_uploads[request_id] = nil
discard_verified_upload(verified.remote_id)
upload_result(src, state.memo.correlation_id, false, "request_failed")
return
end
pending_uploads[request_id] = nil
local memos = list_memos(owner)
local created = find_memo(memos, memo_id)
if not created then
error(("[sky_phone] Created voice memo %s could not be hydrated."):format(memo_id))
end
upload_result(src, state.memo.correlation_id, true, nil, created)
end)
RegisterNetEvent("sky_phone:memos:cancel-upload", function(data)
local src = source
local request_id = type(data) == "table" and data.requestId or nil
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if state and state.source == src and not state.completing then
pending_uploads[request_id] = nil
upload_result(src, state.memo.correlation_id, false, "cancelled")
end
end)
RegisterNetEvent("sky_phone:memos:fail-upload", function(data)
local src = source
local request_id = type(data) == "table" and data.requestId or nil
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if not state or state.source ~= src or state.completing then
return
end
local allowed_errors = {
capture_failed = true,
unsupported = true,
upload_failed = true,
upload_timeout = true,
}
pending_uploads[request_id] = nil
upload_result(src, state.memo.correlation_id, false, allowed_errors[data.error] and data.error or "upload_failed")
end)
AddEventHandler("playerDropped", function()
local src = source
for request_id, state in pairs(pending_uploads) do
if state.source == src then
pending_uploads[request_id] = nil
end
end
end)
end)
+1
View File
@@ -517,6 +517,7 @@ local function bootstrap(source, security, security_loaded)
devices = account_devices(device.account_id, device.imei),
} or nil,
notes = SkyPhoneNotes.List(device.account_id, device.imei),
memos = SkyPhoneMemos.List(device.account_id, device.imei),
}
end
+1
View File
@@ -60,6 +60,7 @@ local RESERVED_APP_IDS = {
music = true,
["neon-drop"] = true,
notes = true,
memos = true,
["number-merge"] = true,
phone = true,
photos = true,
+19 -1
View File
@@ -152,7 +152,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_media` (
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NULL,
`url` TEXT NOT NULL,
`remote_id` VARCHAR(128) NOT NULL,
`media_type` ENUM('photo', 'video') NOT NULL,
`media_type` ENUM('photo', 'video', 'audio') NOT NULL,
`mime_type` VARCHAR(120) NULL,
`origin` ENUM('phone_upload', 'website_import') NOT NULL DEFAULT 'phone_upload',
`source_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
@@ -167,6 +167,24 @@ CREATE TABLE IF NOT EXISTS `sky_phone_media` (
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_voice_memos` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`media_id` BIGINT UNSIGNED NOT NULL,
`title` VARCHAR(120) NOT NULL,
`note` VARCHAR(2000) NOT NULL DEFAULT '',
`duration_ms` INT UNSIGNED NOT NULL,
`size_bytes` INT UNSIGNED NOT NULL,
`waveform` TEXT NOT NULL,
`pinned` TINYINT(1) NOT NULL DEFAULT 0,
`revision` INT UNSIGNED NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_voice_memos_media` (`media_id`),
KEY `idx_sky_phone_voice_memos_list` (`pinned`, `updated_at`, `id`),
FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_contacts` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`contact_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,