mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 22:01:40 +00:00
ADD - voice memo app
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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.',
|
||||
|
||||
@@ -16,6 +16,7 @@ export type BuiltinPhoneAppId =
|
||||
| 'mail'
|
||||
| 'map'
|
||||
| 'notes'
|
||||
| 'memos'
|
||||
| 'radio'
|
||||
| 'music'
|
||||
| 'photos'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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',
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ local RESERVED_APP_IDS = {
|
||||
music = true,
|
||||
["neon-drop"] = true,
|
||||
notes = true,
|
||||
memos = true,
|
||||
["number-merge"] = true,
|
||||
phone = true,
|
||||
photos = true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user