diff --git a/README.md b/README.md index 1648ee4..4b8324a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,22 @@ # sky_phone +## Payphones + +Payphone dialing is validated against server-owned booth positions. Vanilla GTA V positions live +in `sky_phone/config/payphones.lua`; the server compares its own player coordinates with this list +and does not trust coordinates or models sent by the NUI or client. + +For a custom map or MLO, add each booth to `Config.Payphones.Locations` in that server-only file: + +```lua +{ model = "prop_phonebox_01a", coords = { x = 123.45, y = 678.90, z = 21.0 } }, +``` + +The model must also be present in `Config.Payphones.Props`. Coordinates must be finite Lua numbers +inside the supported world bounds. Restart `sky_phone` after changing the list. If payphones are +enabled but the list is empty or contains invalid entries, the server logs a visible warning and +rejects calls that cannot be matched to a valid configured location. + ## Custom Apps `sky_phone` erkennt Registrierungen gestarteter Fremd-App-Ressourcen über integrierte @@ -91,6 +108,20 @@ included in the NUI bundle. Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly. Phone identity and SIM-card behavior are selected independently through `Config.Phone.Unique` and `Config.Sim.Enabled`. +### Ingame test data + +On development servers, enable `Config.TestData.Enabled` and run `/phonetestdata` as the player who +owns the phone. The command creates or refreshes idempotent, player-scoped fixtures for contacts, +calls, messages, mail, notes, gallery, banking history, billing, calendar, map markers, music, radio, +EasyShare, CityMarkt, Local Pages, Picstagram, FlipTok, Feather, Flare, DarkChat, CrewLink, SkyRide, +and company requests. It also creates a linked iFruit account and a registered SIM when the selected +phone does not have them yet. Reopen the phone after the command completes. + +Garage and Housing intentionally continue to use the real configured provider data. Apps without +persistent content, such as Calculator, Camera, Clock, Weather, Settings, and Payphone, do not need +database fixtures. Set `Config.TestData.AdminOnly = true` to restrict the command to the configured +framework admin groups, and disable the feature outside development environments. + An iFruit account is optional. Unlinked devices retain local settings, alarms, media, apps, notes, contacts, and recent calls. Linking from Mail or Settings moves local data into an empty cloud account; an existing cloud dataset wins over local contacts and recents. Signing out keeps an editable local snapshot without deleting cloud data. ## Phone identity and SIM modes @@ -153,23 +184,25 @@ 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 the - server-only `Config.Media.FiveManage.ApiKey` in `sky_phone/config/media.lua`; 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 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: + +```cfg +set sky_phone_fivemanage_api_key "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 in `sky_phone/config/config.lua`: +Configure GIF search as a server-only convar: -```lua -Config.Media.GiphyApiKey = "YOUR_GIPHY_API_KEY" +```cfg +set sky_phone_giphy_api_key "replace-with-your-giphy-api-key" ``` -GIPHY provides trending and searched GIFs through a paginated server-side proxy. The shared -`config.lua` is loaded by both FiveM runtimes, so its values are available to clients even though -only the server uses the GIPHY key. Photo and video actions in Messages are intentionally inactive -until their dedicated implementation is available. +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. 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`. iFruit passwords are intentional in-character credentials and remain plaintext `VARCHAR(64)` values; registration screens warn players never to reuse a real password. @@ -228,7 +261,24 @@ The homescreen is an original implementation inspired by the interaction and lay ## Development -From `frontend/`, run `pnpm dev` for browser development. The phone opens automatically and NUI callbacks are mocked. Feather can be opened directly with the following browser scenarios: +From `frontend/`, run `pnpm dev` for browser development. The phone opens automatically and NUI callbacks are mocked with stateful data. Every built-in app can be opened directly by appending its id to `http://localhost:5174/?apiPort=3002#/apps/`: + +| Area | App ids | +| ------------------- | ------------------------------------------------------------------------------------------------------------- | +| Communication | `phone`, `messages`, `mail`, `darkchat`, `radio` | +| Social | `feather`, `fliptok`, `picstagram`, `flare`, `crewlink` | +| Services | `companies`, `citymarkt`, `local-pages`, `banking`, `billing`, `garage`, `house`, `map`, `skyride`, `weather` | +| Media and utilities | `camera`, `photos`, `music`, `calendar`, `notes`, `calculator`, `clock`, `app-store`, `settings` | +| Games | `snake`, `memory`, `number-merge`, `minesweeper`, `tower-stack`, `sky-flappy`, `neon-drop` | + +The browser bootstrap includes contacts, calls, messages, mail, invoices, transactions, vehicles, properties, companies, marketplace profiles and listings, social feeds, media, calendar entries, notes, alarms, game high scores, app settings, and persisted notifications. Mutating callbacks update the in-memory mock state until the mock server restarts. Unknown callbacks fail with `mock_endpoint_missing` instead of silently succeeding. + +System overlays are available through dedicated preview parameters: + +- SIM picker: `http://localhost:5174/?apiPort=3002&simPickerPreview=1` +- Payphone: `http://localhost:5174/?apiPort=3002&payphonePreview=1` (dial `5551110001` for a connected call or `5550000000` for a busy line) + +Feather can be opened directly with the following browser scenarios: - Full data: `http://localhost:5174/?apiPort=3002#/apps/feather` - Login and registration: `http://localhost:5174/?apiPort=3002&testScenario=feather-login#/apps/feather` diff --git a/frontend/package.json b/frontend/package.json index 69b2228..12fac4a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ "typecheck": "vue-tsc --build", "lint": "eslint .", "format": "prettier --write src/ testserver/ build.cjs", - "test": "vitest run" + "test": "vitest run && pnpm test:browser-mocks", + "test:browser-mocks": "node testserver/smoke.cjs" }, "dependencies": { "emoji-picker-element-data": "^1.8.0", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a89889c..69ea033 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -238,6 +238,9 @@ const REFERENCE_VIEWPORT_WIDTH = 1920 const REFERENCE_VIEWPORT_HEIGHT = 1080 const PHONE_BASE_SCALE = 0.69 const DEVELOPMENT_PHONE_SCALE = 1.25 +const PHONE_PORTRAIT_WIDTH = 390 +const PHONE_PORTRAIT_HEIGHT = 844 +const MIN_PRODUCTION_PHONE_ZOOM = 260 / PHONE_PORTRAIT_WIDTH const isDevelopment = import.meta.env.DEV const phone = usePhoneStore() @@ -293,11 +296,34 @@ const phoneBaseZoom = computed( viewportScale.value * (isDevelopment ? DEVELOPMENT_PHONE_SCALE : PHONE_BASE_SCALE), ) +const phoneZoom = computed(() => { + const preferred = + phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100) + if (isDevelopment) return preferred + + const edgeGap = 24 * viewportScale.value + const shellWidth = phone.cameraLandscape + ? PHONE_PORTRAIT_HEIGHT + : PHONE_PORTRAIT_WIDTH + const shellHeight = phone.cameraLandscape + ? PHONE_PORTRAIT_WIDTH + : PHONE_PORTRAIT_HEIGHT + const viewportMaximum = Math.max( + 0, + Math.min( + (window.innerWidth - edgeGap) / shellWidth, + (window.innerHeight - edgeGap) / shellHeight, + ), + ) + return Math.min( + viewportMaximum, + Math.max(MIN_PRODUCTION_PHONE_ZOOM, preferred), + ) +}) const phoneResolutionStyle = computed(() => ({ '--phone-edge-gap': `${24 * viewportScale.value}px`, '--phone-stack-gap': `${16 * viewportScale.value}px`, - '--phone-zoom': - phoneBaseZoom.value * (phone.preferences.settings.phoneScale / 100), + '--phone-zoom': phoneZoom.value, })) const phoneStageStyle = computed(() => ({ ...phoneResolutionStyle.value, @@ -317,6 +343,8 @@ let pendingCompaniesChange: CompanyChangedPayload | null = null let unlockTimer: number | undefined let passcodeLockTimer: number | undefined let unlockedServicesFrame: number | undefined +let phoneClosePending = false +let simPickerClosePending = false function getViewportScale(): number { const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT @@ -431,6 +459,50 @@ async function hydrateDevelopmentPhone(): Promise { }) } +function openDevelopmentPayphonePreview(): void { + window.dispatchEvent( + new MessageEvent('message', { + data: { + data: { + currency: '$', + locales: { + busy: 'LINE BUSY', + call: 'CALL', + callEnded: 'CALL ENDED', + clear: 'Clear number', + close: 'Close payphone', + connected: 'CONNECTED', + cost: 'COST', + declined: 'CALL DECLINED', + delete: 'Delete digit', + dialing: 'DIALING', + disconnected: 'DISCONNECTED', + elapsed: 'TIME', + hangup: 'HANG UP', + insufficientFunds: 'OUT OF MONEY', + invalidNumber: 'ENTER A VALID NUMBER', + keypad: 'Dial pad', + noAnswer: 'NO ANSWER', + numberLabel: 'NUMBER TO CALL', + numberPlaceholder: 'Enter a phone number', + rate: '{currency}{price} / SEC', + ready: 'READY', + requestFailed: 'CALL COULD NOT BE STARTED', + ringing: 'RINGING', + subtitle: 'PUBLIC TELEPHONE', + title: 'PAYPHONE', + unavailable: 'NUMBER UNAVAILABLE', + voiceUnavailable: 'VOICE SERVICE UNAVAILABLE', + }, + maxNumberLength: 10, + pricePerSecond: 2, + }, + type: 'payphone:open', + }, + }), + ) +} + function onMessage(event: MessageEvent): void { if (!isTrustedRootMessageSource(event.source, window)) return @@ -467,7 +539,7 @@ function onMessage(event: MessageEvent): void { hydratePhone(event.data.data as PhoneOpenPayload) } else if (event.data?.type === 'app:close') { activitySuspended.value = false - phone.close() + phone.endDeviceSession() } else if (event.data?.type === 'app:suspend') { activitySuspended.value = true } else if (event.data?.type === 'app:resume') { @@ -861,14 +933,69 @@ function onMessage(event: MessageEvent): void { } } +async function closeSimPicker(): Promise { + if (simPickerClosePending || !simPicker.value) return + simPickerClosePending = true + const closingPicker = simPicker.value + try { + const response = await nuiCall('sim:picker-close') + if (response.success && simPicker.value === closingPicker) { + simPicker.value = null + } + } finally { + simPickerClosePending = false + } +} + +async function closePhone(): Promise { + if (phoneClosePending || !phone.isOpen) return + phoneClosePending = true + const closingGeneration = phone.persistenceGeneration + const closingImei = phone.device?.imei ?? null + const closingToken = phone.deviceSessionToken + try { + await phone.flushDevicePersistence() + if ( + !phone.isOpen || + phone.persistenceGeneration !== closingGeneration || + (phone.device?.imei ?? null) !== closingImei || + phone.deviceSessionToken !== closingToken + ) { + return + } + const response = await nuiCall('close') + if ( + !response.success || + !phone.isOpen || + phone.persistenceGeneration !== closingGeneration || + (phone.device?.imei ?? null) !== closingImei || + phone.deviceSessionToken !== closingToken + ) { + return + } + phone.endDeviceSession() + } finally { + phoneClosePending = false + } +} + function onKeydown(event: KeyboardEvent): void { - if (event.key !== 'Escape' || !phone.isOpen || activitySuspended.value) return - if (controlCenterOpened.value) { - controlCenterOpened.value = false + if (event.key !== 'Escape') return + if (simPicker.value) { + event.preventDefault() + void closeSimPicker() return } - phone.close() - void nuiCall('close') + + queueMicrotask(() => { + if (event.defaultPrevented || !phone.isOpen || activitySuspended.value) + return + if (controlCenterOpened.value) { + controlCenterOpened.value = false + return + } + void closePhone() + }) } function onSystemColorSchemeChange(event: MediaQueryListEvent): void { @@ -1013,7 +1140,7 @@ onMounted(() => { window.addEventListener('resize', updateViewportScale) systemColorScheme.addEventListener('change', onSystemColorSchemeChange) phone.setSystemDarkMode(systemColorScheme.matches) - void nuiCall('ui:ready') + void nuiCall('ui:ready', { protocolVersion: 1 }) clockTicker = setInterval(() => { const now = Date.now() for (const alarm of clock.dueAlarms(now)) { @@ -1062,24 +1189,31 @@ onMounted(() => { number: '5551234567', } } + if (developmentParameters.has('payphonePreview')) { + openDevelopmentPayphonePreview() + } } }) watch( () => route.params.appId, (appId) => { - if (typeof appId === 'string' && isPhoneAppId(appId)) { + if ( + phone.isOpen && + phone.device?.imei && + appStore.hydrated && + typeof appId === 'string' && + isPhoneAppId(appId) + ) { appStore.recordLaunch(appId) } }, ) watch( - [() => notifications.requiresAttention, () => calls.activeCall], - ([requiresAttention, activeCall]) => { - void nuiCall('notification:focus', { - active: requiresAttention || activeCall !== null, - }) + () => notifications.requiresAttention, + (requiresAttention) => { + void nuiCall('notification:focus', { active: requiresAttention }) }, ) @@ -1163,7 +1297,7 @@ onBeforeUnmount(() => { v-if="simPicker" :choices="simPicker.choices" :number="simPicker.number" - @close="simPicker = null" + @close="closeSimPicker" />
{ :style="phoneDisplayStyle" :class="{ dark: phone.isDarkMode, + 'phone-app--darkchat': route.params.appId === 'darkchat', 'phone-app--light': !phone.isDarkMode, + 'phone-app--messages': route.params.appId === 'messages', [`phone-app--${phone.preferences.settings.graphicsMode}`]: true, 'phone-app--unlocking': isUnlocking, }" diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 10e108c..2769c81 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -14,9 +14,13 @@ .sim-picker { position: relative; width: min(58vh, 90vw); - max-height: 46vh; - overflow: clip; + max-width: calc(100vw - 32px); + max-height: calc(100vh - 32px); + min-height: 0; + overflow: hidden; padding: 2.2vh; + display: flex; + flex-direction: column; border: 0.1vh solid rgb(255 255 255 / 12%); border-radius: 0.9vh; background: #050505; @@ -56,11 +60,16 @@ .sim-picker__header { display: flex; + flex: 0 0 auto; justify-content: space-between; gap: 2vh; margin-bottom: 1.6vh; } +.sim-picker__header > div { + min-width: 0; +} + .sim-picker__header h1, .sim-picker__confirmation h2 { margin: 0; @@ -73,6 +82,7 @@ margin: 0.45vh 0 0; color: #9ca3af; font-size: 1.15vh; + overflow-wrap: anywhere; } .sim-picker__close { @@ -80,6 +90,8 @@ place-items: center; width: 2.8vh; height: 2.8vh; + min-width: 32px; + min-height: 32px; border: 0; border-radius: 0.35vh; background: #1dd1ce; @@ -96,10 +108,12 @@ position: relative; z-index: 1; display: grid; + flex: 1 1 auto; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1vh; - max-height: 34vh; - overflow: auto; + min-height: 0; + max-height: 54vh; + overflow-y: auto; } .sim-picker__card { @@ -140,30 +154,34 @@ } .sim-picker__details strong { - overflow: hidden; font-size: 1.2vh; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } .sim-picker__details small { color: #9ca3af; font-size: 0.95vh; + overflow-wrap: anywhere; } .sim-picker__confirmation { + min-height: 0; + overflow-y: auto; padding: 2vh 0 0; text-align: center; } .sim-picker__confirmation > div { display: flex; + flex-wrap: wrap; justify-content: center; gap: 1vh; margin-top: 2vh; } .sim-picker__confirmation button { + min-height: 40px; + overflow-wrap: anywhere; padding: 0.8vh 1.6vh; border: 0.1vh solid rgb(255 255 255 / 18%); border-radius: 0.45vh; @@ -184,6 +202,13 @@ color: #ff453a; font-size: 1vh; text-align: center; + overflow-wrap: anywhere; +} + +@media (max-width: 640px) { + .sim-picker__cards { + grid-template-columns: 1fr; + } } :root { font-family: @@ -205,6 +230,7 @@ body, margin: 0; overflow: hidden; background: transparent !important; + pointer-events: none; user-select: none; } button, @@ -885,13 +911,13 @@ button { } /* DarkChat is intentionally independent from the phone appearance setting. */ -.phone-app:has(.darkchat-page), -.phone-app:has(.darkchat-page) .phone-app-window, -.phone-app:has(.darkchat-page) .phone-app-window__content { +.phone-app--darkchat, +.phone-app--darkchat .phone-app-window, +.phone-app--darkchat .phone-app-window__content { background: #000 !important; color-scheme: dark; } -.phone-app:has(.darkchat-page) .phone-status-bar { +.phone-app--darkchat .phone-status-bar { color: #fff !important; --status-bar-color: #fff; } @@ -1592,7 +1618,7 @@ button { font-size: 9px; white-space: nowrap; } -.darkchat-message:has(.darkchat-reactions) { +.darkchat-message { margin-bottom: 12px; } .darkchat-replying { @@ -3714,7 +3740,7 @@ button { .clock-world-time { margin-top: 8px; font-variant-numeric: tabular-nums; - font-size: min(76px, 20cqw); + font-size: 74px; font-weight: 200; letter-spacing: -4px; } @@ -3861,7 +3887,7 @@ button { .clock-digits { margin: 70px 0 54px; font-variant-numeric: tabular-nums; - font-size: min(52px, 14cqw); + font-size: 52px; font-weight: 200; } .clock-sound-menu { @@ -4242,11 +4268,11 @@ button { background: #fff !important; color: #111; } -.phone-app:has(.messages-page) .phone-status-bar { +.phone-app--messages .phone-status-bar { color: #080808; text-shadow: none; } -.phone-app:has(.messages-page)::before { +.phone-app--messages::before { content: ''; position: absolute; z-index: 60; @@ -5458,7 +5484,7 @@ button { } } /* iOS 26-style Liquid Glass refinements and complete media pickers. */ -.phone-app:has(.messages-page) { +.messages-page { --ios-glass: rgb(252 252 255 / 70%); --ios-glass-strong: rgb(255 255 255 / 86%); --ios-glass-border: rgb(255 255 255 / 72%); @@ -5779,21 +5805,21 @@ button { .messages-attachment--video small { z-index: 2; } -.messages-attachment--gif:has(img) { +.messages-attachment--gif { background: #e9e9ed; } /* Konsta's k-app dark state drives the complete iOS Messages palette. */ -.phone-app.dark:has(.messages-page) { +.phone-app.dark .messages-page { --ios-glass: rgb(28 28 30 / 76%); --ios-glass-strong: rgb(36 36 38 / 90%); --ios-glass-border: rgb(255 255 255 / 12%); color-scheme: dark; } -.phone-app.dark:has(.messages-page)::before { +.phone-app--messages.dark::before { background: #000; } -.phone-app.dark:has(.messages-page) .phone-status-bar { +.phone-app--messages.dark .phone-status-bar { color: #fff; text-shadow: none; } diff --git a/frontend/src/components/AppIcon.vue b/frontend/src/components/AppIcon.vue index 17fb331..8e2da12 100644 --- a/frontend/src/components/AppIcon.vue +++ b/frontend/src/components/AppIcon.vue @@ -12,6 +12,10 @@ import { useMarketplaceStore } from '@/stores/marketplace' import { useDarkChatStore } from '@/stores/darkchat' import { usePhoneStore } from '@/stores/phone' import type { PhoneAppDefinition } from '@/types/apps' +import { + reorderDirectionFromKeyboard, + type ReorderDirection, +} from '@/utils/keyboard' const props = withDefaults( defineProps<{ @@ -33,6 +37,7 @@ const emit = defineEmits<{ dragstart: [event: PointerEvent] edit: [] remove: [] + reorder: [direction: ReorderDirection] }>() const phone = usePhoneStore() @@ -65,6 +70,8 @@ const suppressClick = ref(false) let holdTimer: number | undefined let calendarTimer: number | undefined let pointerStart = { x: 0, y: 0 } +let pointerTarget: HTMLElement | null = null +let pointerId: number | null = null watch( () => props.app.iconImage, @@ -135,6 +142,9 @@ function clearHold(): void { function onPointerDown(event: PointerEvent): void { if (props.compact || event.button !== 0) return + pointerTarget = event.currentTarget as HTMLElement + pointerId = event.pointerId + pointerTarget.setPointerCapture(pointerId) pointerStart = { x: event.clientX, y: event.clientY } clearHold() if (props.editMode) { @@ -173,35 +183,48 @@ function beginPointerDrag(event: PointerEvent): void { .closest('.springboard-page') ?.getBoundingClientRect().width ?? 0 isDragging.value = true - window.addEventListener('pointermove', onPointerMove) - window.addEventListener('pointerup', onPointerUp) - window.addEventListener('pointercancel', cancelPointerDrag) emit('dragstart', event) } function onPointerUp(event: PointerEvent): void { clearHold() - if (!isDragging.value) return - suppressClick.value = true - emit('dragend', event) - isDragging.value = false - dragOffset.value = { x: 0, y: 0 } - removeDragListeners() + if (isDragging.value) { + suppressClick.value = true + emit('dragend', event) + isDragging.value = false + dragOffset.value = { x: 0, y: 0 } + } + releasePointerCapture() } function cancelPointerDrag(): void { clearHold() - if (!isDragging.value) return + const wasDragging = isDragging.value isDragging.value = false dragOffset.value = { x: 0, y: 0 } - removeDragListeners() - emit('dragcancel') + releasePointerCapture() + if (wasDragging) emit('dragcancel') } -function removeDragListeners(): void { - window.removeEventListener('pointermove', onPointerMove) - window.removeEventListener('pointerup', onPointerUp) - window.removeEventListener('pointercancel', cancelPointerDrag) +function releasePointerCapture(): void { + if ( + pointerTarget && + pointerId !== null && + pointerTarget.hasPointerCapture(pointerId) + ) { + pointerTarget.releasePointerCapture(pointerId) + } + pointerTarget = null + pointerId = null +} + +function onKeydown(event: KeyboardEvent): void { + if (!props.editMode) return + const direction = reorderDirectionFromKeyboard(event) + if (!direction) return + event.preventDefault() + event.stopPropagation() + emit('reorder', direction) } onMounted(() => { @@ -214,7 +237,7 @@ onMounted(() => { onBeforeUnmount(() => { clearHold() if (calendarTimer !== undefined) window.clearInterval(calendarTimer) - removeDragListeners() + releasePointerCapture() }) @@ -234,10 +257,15 @@ onBeforeUnmount(() => { type="button" :aria-label="getPhoneAppLabel(app, phone.t)" :aria-disabled="!app.route" + :aria-keyshortcuts=" + editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined + " @click="launch" @contextmenu.prevent + @keydown="onKeydown" @pointercancel="cancelPointerDrag" @pointerdown="onPointerDown" + @lostpointercapture="cancelPointerDrag" @pointerleave="isDragging || clearHold()" @pointermove="onPointerMove" @pointerup="onPointerUp" diff --git a/frontend/src/components/CustomAppFrame.vue b/frontend/src/components/CustomAppFrame.vue index bc6d973..135be23 100644 --- a/frontend/src/components/CustomAppFrame.vue +++ b/frontend/src/components/CustomAppFrame.vue @@ -447,6 +447,8 @@ watch(() => catalog.openRequests[props.app.id], flushOpenRequest, { right: auto; bottom: auto; left: 50%; + width: 827px; + height: 368px; width: 100cqh; height: 100cqw; transform: translate(-50%, -50%) rotate(90deg); diff --git a/frontend/src/components/DarkChatSelect.vue b/frontend/src/components/DarkChatSelect.vue index 804ae57..baa33d1 100644 --- a/frontend/src/components/DarkChatSelect.vue +++ b/frontend/src/components/DarkChatSelect.vue @@ -35,7 +35,10 @@ function closeFromOutside(event: PointerEvent): void { } function closeFromEscape(event: KeyboardEvent): void { - if (event.key === 'Escape') opened.value = false + if (event.key !== 'Escape' || !opened.value) return + event.preventDefault() + event.stopPropagation() + opened.value = false } onMounted(() => { diff --git a/frontend/src/components/EasyShareSheet.vue b/frontend/src/components/EasyShareSheet.vue index 9e83d30..48da343 100644 --- a/frontend/src/components/EasyShareSheet.vue +++ b/frontend/src/components/EasyShareSheet.vue @@ -9,7 +9,7 @@ import { UserRound, X, } from 'lucide-vue-next' -import { computed, ref } from 'vue' +import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { getPhoneApp, getPhoneAppLabel } from '@/config/apps' @@ -30,6 +30,7 @@ import { easyShareDestinationAppIds, openEasySharePayload, } from '@/utils/easyshare' +import { consumeEscape } from '@/utils/keyboard' const phone = usePhoneStore() const appStore = useAppStoreStore() @@ -113,11 +114,8 @@ const shareApps = computed(() => return app ? [{ app, id }] : [] }), ) -const sheetStyle = computed(() => ({ - transform: easyShare.opened - ? `translateY(calc(-100% + ${dragOffset.value}px))` - : undefined, - transitionDuration: dragging.value ? '0ms' : undefined, +const hostStyle = computed(() => ({ + '--easyshare-drag-offset': `${dragOffset.value}px`, })) function label(key: string, params?: Record): string { @@ -155,6 +153,11 @@ function close(): void { easyShare.close() } +function onKeydown(event: KeyboardEvent): void { + if (!easyShare.opened || !consumeEscape(event)) return + close() +} + function beginDrag(event: PointerEvent): void { if (!easyShare.opened || event.button !== 0) return dragPointerId = event.pointerId @@ -240,17 +243,22 @@ async function openTransfer(transfer: EasyShareTransfer): Promise { close() await openEasySharePayload(router, transfer.payload) } + +onMounted(() => window.addEventListener('keydown', onKeydown, true)) +onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown, true)) diff --git a/frontend/src/stores/banking.test.ts b/frontend/src/stores/banking.test.ts index 338c39b..52e1062 100644 --- a/frontend/src/stores/banking.test.ts +++ b/frontend/src/stores/banking.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useBankingStore } from '@/stores/banking' import type { BankingOverview } from '@/types/banking' -import { nuiCall } from '@/utils/nui' +import { nuiCall, type NuiResponse } from '@/utils/nui' vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) @@ -60,4 +60,26 @@ describe('banking store', () => { expect(banking.overview).toEqual(overview) expect(banking.error).toBe('insufficient_funds') }) + + it('does not let an older response overwrite the newest overview', async () => { + let resolveOlder!: (response: NuiResponse) => void + const olderResponse = new Promise>( + (resolve) => { + resolveOlder = resolve + }, + ) + const newest = { ...overview, bank: 23000 } + mockNuiCall + .mockReturnValueOnce(olderResponse) + .mockResolvedValueOnce({ data: newest, success: true }) + const banking = useBankingStore() + + const olderRequest = banking.load() + await banking.load() + resolveOlder({ data: { ...overview, bank: 1 }, success: true }) + await olderRequest + + expect(banking.overview).toEqual(newest) + expect(banking.isLoading).toBe(false) + }) }) diff --git a/frontend/src/stores/banking.ts b/frontend/src/stores/banking.ts index dab3572..4c8ba80 100644 --- a/frontend/src/stores/banking.ts +++ b/frontend/src/stores/banking.ts @@ -8,12 +8,21 @@ export const useBankingStore = defineStore('banking', { error: '', isLoading: false, overview: null as BankingOverview | null, + pendingRequests: 0, + requestGeneration: 0, }), actions: { async load(): Promise { + const generation = ++this.requestGeneration + this.pendingRequests += 1 this.isLoading = true - const response = await nuiCall('banking:overview') - this.isLoading = false + const response = await nuiCall('banking:overview').finally( + () => { + this.pendingRequests = Math.max(0, this.pendingRequests - 1) + this.isLoading = this.pendingRequests > 0 + }, + ) + if (generation !== this.requestGeneration) return response.success if (response.success && response.data) { this.overview = response.data this.error = '' @@ -27,12 +36,17 @@ export const useBankingStore = defineStore('banking', { amount: number, phoneNumber?: string, ): Promise> { + const generation = ++this.requestGeneration + this.pendingRequests += 1 this.isLoading = true const response = await nuiCall(`banking:${action}`, { amount, ...(phoneNumber === undefined ? {} : { phoneNumber }), + }).finally(() => { + this.pendingRequests = Math.max(0, this.pendingRequests - 1) + this.isLoading = this.pendingRequests > 0 }) - this.isLoading = false + if (generation !== this.requestGeneration) return response if (response.success && response.data) { this.overview = response.data this.error = '' diff --git a/frontend/src/stores/mail.test.ts b/frontend/src/stores/mail.test.ts index bcf80cc..17e7a3a 100644 --- a/frontend/src/stores/mail.test.ts +++ b/frontend/src/stores/mail.test.ts @@ -1,9 +1,10 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAccountStore } from '@/stores/account' import { useMailStore } from '@/stores/mail' -import type { MailCounts, MailListItem } from '@/types/mail' -import { nuiCall } from '@/utils/nui' +import type { MailCounts, MailListItem, MailListResponse } from '@/types/mail' +import { nuiCall, type NuiResponse } from '@/utils/nui' vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn(), @@ -45,7 +46,7 @@ describe('mail store', () => { success: true, }) .mockResolvedValueOnce({ - data: { hasMore: false, items: [listItem(2)] }, + data: { hasMore: false, items: [listItem(2)], offset: 0 }, success: true, }) @@ -129,4 +130,103 @@ describe('mail store', () => { expect(mail.folder).toBe('inbox') expect(mail.search).toBe('') }) + + it('ignores an older folder response after a newer navigation', async () => { + let resolveOlder!: (response: NuiResponse) => void + const olderResponse = new Promise>( + (resolve) => { + resolveOlder = resolve + }, + ) + mockNuiCall + .mockReturnValueOnce(olderResponse) + .mockResolvedValueOnce({ + data: { hasMore: false, items: [listItem(2)] }, + success: true, + }) + const mail = useMailStore() + + const olderRequest = mail.loadFolder('inbox') + await mail.loadFolder('sent') + resolveOlder({ + data: { hasMore: false, items: [listItem(1)], offset: 0 }, + success: true, + }) + await olderRequest + + expect(mail.folder).toBe('sent') + expect(mail.items.map((item) => item.id)).toEqual([2]) + expect(mail.loading).toBe(false) + }) + + it('ignores mailbox counts returned after the session was cleared', async () => { + let resolveCounts!: (response: NuiResponse) => void + mockNuiCall.mockReturnValueOnce( + new Promise>((resolve) => { + resolveCounts = resolve + }), + ) + const mail = useMailStore() + + const bootstrap = mail.bootstrap('alex@ifruit.com') + await mail.bootstrap('') + resolveCounts({ data: counts, success: true }) + await bootstrap + + expect(mail.accountEmail).toBe('') + expect(mail.counts).toEqual({ + drafts: 0, + inbox: 0, + sent: 0, + trash: 0, + unread: 0, + }) + }) + + it('ignores a late login after the mailbox session was cleared', async () => { + let resolveLogin!: (response: NuiResponse<{ devices: []; email: string }>) => void + mockNuiCall.mockReturnValueOnce( + new Promise>((resolve) => { + resolveLogin = resolve + }), + ) + const mail = useMailStore() + const account = useAccountStore() + + const login = mail.login('alex', 'secret') + await mail.bootstrap('') + resolveLogin({ + data: { devices: [], email: 'alex@ifruit.com' }, + success: true, + }) + await login + + expect(mail.accountEmail).toBe('') + expect(account.email).toBe('') + }) + + it('ignores a late login after an external mailbox session change', async () => { + let resolveLogin!: (response: NuiResponse<{ devices: []; email: string }>) => void + mockNuiCall + .mockReturnValueOnce( + new Promise>((resolve) => { + resolveLogin = resolve + }), + ) + .mockResolvedValueOnce({ data: counts, success: true }) + const mail = useMailStore() + const account = useAccountStore() + + const login = mail.login('alex', 'secret') + account.hydrate({ devices: [], email: 'morgan@ifruit.com' }) + await mail.bootstrap('morgan@ifruit.com') + resolveLogin({ + data: { devices: [], email: 'alex@ifruit.com' }, + success: true, + }) + await login + + expect(mail.accountEmail).toBe('morgan@ifruit.com') + expect(account.email).toBe('morgan@ifruit.com') + }) }) diff --git a/frontend/src/stores/mail.ts b/frontend/src/stores/mail.ts index 238bea6..01a4c5b 100644 --- a/frontend/src/stores/mail.ts +++ b/frontend/src/stores/mail.ts @@ -31,14 +31,21 @@ export const useMailStore = defineStore('mail', () => { const items = ref([]) const loading = ref(false) const search = ref('') + let authenticationGeneration = 0 + let folderRequestGeneration = 0 + let sessionGeneration = 0 function clearSession(): void { + authenticationGeneration += 1 + sessionGeneration += 1 + folderRequestGeneration += 1 accountEmail.value = '' counts.value = emptyCounts() items.value = [] hasMore.value = false folder.value = 'inbox' search.value = '' + loading.value = false } async function bootstrap(email: string): Promise { @@ -46,16 +53,24 @@ export const useMailStore = defineStore('mail', () => { clearSession() return } + authenticationGeneration += 1 + sessionGeneration += 1 + folderRequestGeneration += 1 accountEmail.value = email await refreshCounts() } async function login(email: string, password: string) { + const generation = ++authenticationGeneration const response = await nuiCall('mail:login', { email, password, }) - if (response.success && response.data) { + if ( + generation === authenticationGeneration && + response.success && + response.data + ) { account.hydrate(response.data) await bootstrap(response.data.email) } @@ -63,11 +78,16 @@ export const useMailStore = defineStore('mail', () => { } async function register(email: string, password: string) { + const generation = ++authenticationGeneration const response = await nuiCall('mail:register', { email, password, }) - if (response.success && response.data) { + if ( + generation === authenticationGeneration && + response.success && + response.data + ) { account.hydrate(response.data) await bootstrap(response.data.email) } @@ -75,10 +95,13 @@ export const useMailStore = defineStore('mail', () => { } async function logout(): Promise { + const generation = ++authenticationGeneration if (accountEmail.value) { const response = await nuiCall('mail:logout') + if (generation !== authenticationGeneration) return if (response.success) account.hydrate(null) } + if (generation !== authenticationGeneration) return clearSession() } @@ -87,6 +110,8 @@ export const useMailStore = defineStore('mail', () => { nextSearch = '', append = false, ): Promise { + const generation = ++folderRequestGeneration + const session = sessionGeneration loading.value = true const offset = append ? items.value.length : 0 const response = await nuiCall('mail:list', { @@ -94,7 +119,13 @@ export const useMailStore = defineStore('mail', () => { offset, search: nextSearch, }) - loading.value = false + if (generation === folderRequestGeneration) loading.value = false + if ( + generation !== folderRequestGeneration || + session !== sessionGeneration + ) { + return false + } if (!response.success || !response.data) return false folder.value = nextFolder @@ -107,8 +138,17 @@ export const useMailStore = defineStore('mail', () => { } async function refreshCounts(): Promise { + const email = accountEmail.value + const session = sessionGeneration const response = await nuiCall('mail:counts') - if (response.success && response.data) counts.value = response.data + if ( + session === sessionGeneration && + email === accountEmail.value && + response.success && + response.data + ) { + counts.value = response.data + } } async function openMessage(id: number): Promise { diff --git a/frontend/src/stores/notifications.test.ts b/frontend/src/stores/notifications.test.ts index e38b885..59eff5f 100644 --- a/frontend/src/stores/notifications.test.ts +++ b/frontend/src/stores/notifications.test.ts @@ -2,6 +2,7 @@ import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + MAX_LOCK_SCREEN_NOTIFICATIONS, useNotificationsStore, type PhoneNotificationDevice, } from '@/stores/notifications' @@ -256,4 +257,26 @@ describe('notifications store', () => { notifications.clearLockScreen() expect(notifications.lockScreenNotifications).toEqual([]) }) + + it('bounds persisted lock screen history to the newest notifications', () => { + openPhone('111') + const notifications = useNotificationsStore() + const items = Array.from( + { length: MAX_LOCK_SCREEN_NOTIFICATIONS + 10 }, + (_, index) => ({ + appId: 'mail' as const, + id: `saved-${index}`, + text: `Message ${index}`, + title: 'Mail', + }), + ) + + notifications.hydrate({ items, version: 1 }, '111') + + expect(notifications.lockScreenNotifications).toHaveLength( + MAX_LOCK_SCREEN_NOTIFICATIONS, + ) + expect(notifications.lockScreenNotifications[0]?.id).toBe('saved-59') + expect(notifications.lockScreenNotifications.at(-1)?.id).toBe('saved-10') + }) }) diff --git a/frontend/src/stores/notifications.ts b/frontend/src/stores/notifications.ts index 1487a2c..2777000 100644 --- a/frontend/src/stores/notifications.ts +++ b/frontend/src/stores/notifications.ts @@ -43,6 +43,8 @@ type PersistedNotificationsV1 = { version: 1 } +export const MAX_LOCK_SCREEN_NOTIFICATIONS = 50 + const timeoutHandles = new Map>() const stopToneHandles = new Map void>() const persistenceQueues = new Map>() @@ -151,7 +153,9 @@ export const useNotificationsStore = defineStore('notifications', () => { for (const notification of stored) merged.set(notification.id, notification) for (const notification of lockScreenQueues.value[imei] ?? []) merged.set(notification.id, notification) - lockScreenQueues.value[imei] = [...merged.values()] + lockScreenQueues.value[imei] = [...merged.values()].slice( + -MAX_LOCK_SCREEN_NOTIFICATIONS, + ) persist(imei) } @@ -166,9 +170,10 @@ export const useNotificationsStore = defineStore('notifications', () => { function remember(notification: PhoneNotification): void { const imei = notification.device?.imei ?? phone.device?.imei if (!imei) return - const notifications = lockScreenQueues.value[imei] ?? [] - notifications.push(notification) - lockScreenQueues.value[imei] = notifications + lockScreenQueues.value[imei] = [ + ...(lockScreenQueues.value[imei] ?? []), + notification, + ].slice(-MAX_LOCK_SCREEN_NOTIFICATIONS) persist(imei) } diff --git a/frontend/src/stores/phone-persistence.test.ts b/frontend/src/stores/phone-persistence.test.ts new file mode 100644 index 0000000..1c4027a --- /dev/null +++ b/frontend/src/stores/phone-persistence.test.ts @@ -0,0 +1,170 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { usePhoneStore } from '@/stores/phone' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ + nuiCall: vi.fn(), +})) + +const mockNuiCall = vi.mocked(nuiCall) + +function deferredResponse(): { + promise: Promise> + resolve: (response: NuiResponse) => void +} { + let resolve!: (response: NuiResponse) => void + const promise = new Promise>((next) => { + resolve = next + }) + return { promise, resolve } +} + +function openPhone(imei: string, token: string, revision: number): void { + usePhoneStore().open({ + device: { + data: { settings: { payload: {}, revision } }, + imei, + name: `Phone ${imei}`, + sim: null, + }, + token, + }) +} + +describe('phone device persistence scope', () => { + beforeEach(() => { + vi.stubGlobal('window', { + matchMedia: vi.fn(() => ({ matches: false })), + }) + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('does not apply a late save response to a newer device session', async () => { + const stale = deferredResponse<{ revision: number }>() + mockNuiCall.mockReturnValueOnce(stale.promise) + const phone = usePhoneStore() + openPhone('111', 'session-a', 2) + + phone.saveDeviceNamespace('settings', { value: 'old' }) + await Promise.resolve() + expect(mockNuiCall).toHaveBeenCalledWith('device:save', { + imei: '111', + namespace: 'settings', + payload: { value: 'old' }, + revision: 2, + sessionToken: 'session-a', + }) + + openPhone('222', 'session-b', 7) + stale.resolve({ data: { revision: 3 }, success: true }) + await stale.promise + await Promise.resolve() + + expect(phone.device?.imei).toBe('222') + expect(phone.deviceRevisions.settings).toBe(7) + }) + + it('drops queued writes from an obsolete device generation', async () => { + const first = deferredResponse<{ revision: number }>() + mockNuiCall.mockReturnValueOnce(first.promise) + const phone = usePhoneStore() + openPhone('111', 'session-a', 0) + + phone.saveDeviceNamespace('settings', { order: 1 }) + phone.saveDeviceNamespace('settings', { order: 2 }) + await Promise.resolve() + openPhone('222', 'session-b', 0) + first.resolve({ data: { revision: 1 }, success: true }) + await first.promise + await Promise.resolve() + await Promise.resolve() + + expect(mockNuiCall).toHaveBeenCalledTimes(1) + }) + + it('flushes every queued write after a normal visibility close', async () => { + const first = deferredResponse<{ revision: number }>() + mockNuiCall + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce({ data: { revision: 2 }, success: true }) + const phone = usePhoneStore() + openPhone('111', 'session-a', 0) + + phone.saveDeviceNamespace('settings', { order: 1 }) + phone.saveDeviceNamespace('settings', { order: 2 }) + await Promise.resolve() + phone.close() + const flushed = phone.flushDevicePersistence() + + first.resolve({ data: { revision: 1 }, success: true }) + await flushed + + expect(mockNuiCall).toHaveBeenCalledTimes(2) + expect(mockNuiCall).toHaveBeenLastCalledWith('device:save', { + imei: '111', + namespace: 'settings', + payload: { order: 2 }, + revision: 1, + sessionToken: 'session-a', + }) + expect(phone.deviceRevisions.settings).toBe(2) + }) + + it('keeps queued writes scoped across a same-session bootstrap update', async () => { + const first = deferredResponse<{ revision: number }>() + mockNuiCall + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce({ data: { revision: 2 }, success: true }) + const phone = usePhoneStore() + openPhone('111', 'session-a', 0) + + phone.saveDeviceNamespace('settings', { order: 1 }) + phone.saveDeviceNamespace('settings', { order: 2 }) + await Promise.resolve() + first.resolve({ data: { revision: 1 }, success: true }) + await first.promise + await Promise.resolve() + openPhone('111', 'session-a', 1) + await phone.flushDevicePersistence() + + expect(mockNuiCall).toHaveBeenCalledTimes(2) + expect(phone.deviceRevisions.settings).toBe(2) + }) + + it('waits for writes queued while a persistence flush is in progress', async () => { + const first = deferredResponse<{ revision: number }>() + const queuedDuringFlush = deferredResponse<{ revision: number }>() + mockNuiCall + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(queuedDuringFlush.promise) + const phone = usePhoneStore() + openPhone('111', 'session-a', 0) + + phone.saveDeviceNamespace('settings', { order: 1 }) + await Promise.resolve() + let flushCompleted = false + const flushed = phone.flushDevicePersistence().then(() => { + flushCompleted = true + }) + phone.saveDeviceNamespace('widgets', { order: 2 }) + await Promise.resolve() + + first.resolve({ data: { revision: 1 }, success: true }) + await first.promise + await Promise.resolve() + expect(flushCompleted).toBe(false) + + queuedDuringFlush.resolve({ data: { revision: 1 }, success: true }) + await flushed + + expect(mockNuiCall).toHaveBeenCalledTimes(2) + expect(phone.deviceRevisions.widgets).toBe(1) + }) +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 2b40dea..1827834 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -12,6 +12,7 @@ import { nuiCall } from '@/utils/nui' import type { NuiResponse } from '@/utils/nui' import { DEFAULT_PHONE_PREFERENCES, + clampPhoneScale, ensureAppNotificationPreferences, parsePhonePreferences, type AppNotificationPreferences, @@ -38,6 +39,7 @@ export type PhoneOpenPayload = { } const namespaceQueues = new Map>() +let nextPersistenceSession = 0 const companiesFallbackLocales = { name: 'Companies', @@ -3688,11 +3690,14 @@ export const usePhoneStore = defineStore('phone', { currentPage: 1, device: null as PhoneDevice | null, deviceRevisions: {} as Record, + deviceSessionToken: null as string | null, isOpen: false, lang: 'en', launchOrigin: null as AppLaunchOrigin | null, locales: defaultLocales, preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES), + persistenceGeneration: 0, + persistenceSession: ++nextPersistenceSession, security: { enabled: false, length: null, @@ -3713,6 +3718,15 @@ export const usePhoneStore = defineStore('phone', { this.isOpen = false }, open(payload: PhoneOpenPayload = {}): void { + const nextImei = payload.device?.imei ?? this.device?.imei ?? null + const nextToken = payload.token ?? this.deviceSessionToken + if ( + nextImei !== (this.device?.imei ?? null) || + nextToken !== this.deviceSessionToken + ) { + this.persistenceGeneration += 1 + } + this.deviceSessionToken = nextToken this.lang = payload.lang ?? 'en' this.locales = payload.locales ?? defaultLocales if (payload.device) this.hydrateDevice(payload.device) @@ -3723,6 +3737,13 @@ export const usePhoneStore = defineStore('phone', { } this.isOpen = true }, + endDeviceSession(): void { + this.close() + if (this.deviceSessionToken !== null) { + this.deviceSessionToken = null + this.persistenceGeneration += 1 + } + }, hydrateDevice(device: PhoneDevice): void { this.device = device this.deviceRevisions = Object.fromEntries( @@ -3736,22 +3757,59 @@ export const usePhoneStore = defineStore('phone', { ) }, saveDeviceNamespace(namespace: string, payload: unknown): void { - const previous = namespaceQueues.get(namespace) ?? Promise.resolve() + const imei = this.device?.imei + if (!imei) { + console.error( + `[Phone persistence] Could not save ${namespace} without an active device.`, + ) + return + } + const generation = this.persistenceGeneration + const session = this.persistenceSession + const token = this.deviceSessionToken + const queuedPayload = cloneJsonData(payload) + const queueKey = `${session}:${generation}:${imei}:${namespace}` + const isCurrentScope = (): boolean => + this.persistenceSession === session && + this.persistenceGeneration === generation && + this.device?.imei === imei && + this.deviceSessionToken === token + const previous = namespaceQueues.get(queueKey) ?? Promise.resolve() const queued = previous.then(async () => { + if (!isCurrentScope()) return const response = await nuiCall<{ revision: number }>('device:save', { + imei, namespace, - payload, + payload: queuedPayload, revision: this.deviceRevisions[namespace] ?? 0, + sessionToken: token, }) - if (response.success && response.data) { - this.deviceRevisions[namespace] = response.data.revision + if ( + isCurrentScope() && + response.success && + Number.isInteger(response.data?.revision) && + Number(response.data?.revision) >= 0 + ) { + this.deviceRevisions[namespace] = Number(response.data?.revision) } }) const tracked = queued.finally(() => { - if (namespaceQueues.get(namespace) === tracked) - namespaceQueues.delete(namespace) + if (namespaceQueues.get(queueKey) === tracked) + namespaceQueues.delete(queueKey) }) - namespaceQueues.set(namespace, tracked) + namespaceQueues.set(queueKey, tracked) + }, + async flushDevicePersistence(): Promise { + const imei = this.device?.imei + if (!imei) return + const queuePrefix = `${this.persistenceSession}:${this.persistenceGeneration}:${imei}:` + while (true) { + const activeQueues = [...namespaceQueues.entries()] + .filter(([key]) => key.startsWith(queuePrefix)) + .map(([, queue]) => queue) + if (!activeQueues.length) return + await Promise.all(activeQueues) + } }, setCurrentPage(page: number, pageCount?: number): void { this.currentPage = clampPage(page, pageCount) @@ -3777,7 +3835,9 @@ export const usePhoneStore = defineStore('phone', { key: K, value: PhonePreferencesV1['settings'][K], ): void { - this.preferences.settings[key] = value + this.preferences.settings[key] = ( + key === 'phoneScale' ? clampPhoneScale(Number(value)) : value + ) as PhonePreferencesV1['settings'][K] this.saveDeviceNamespace('settings', this.preferences) }, setAlertVolumes(value: number): void { diff --git a/frontend/src/utils/gameView.test.ts b/frontend/src/utils/gameView.test.ts index b218a9c..39112dd 100644 --- a/frontend/src/utils/gameView.test.ts +++ b/frontend/src/utils/gameView.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' -import { gameViewGeometry } from '@/utils/gameView' +import { createGameView, gameViewGeometry } from '@/utils/gameView' describe('gameViewGeometry', () => { it('center-crops a widescreen game view for 3:4 portrait output', () => { @@ -60,3 +60,92 @@ describe('gameViewGeometry', () => { ]) }) }) + +describe('createGameView', () => { + it('recreates graphics resources and resumes after context restoration', () => { + const gl = { + ARRAY_BUFFER: 1, + CLAMP_TO_EDGE: 2, + COLOR_BUFFER_BIT: 4, + COMPILE_STATUS: 5, + DYNAMIC_DRAW: 6, + FLOAT: 7, + FRAGMENT_SHADER: 8, + LINK_STATUS: 9, + MIRRORED_REPEAT: 10, + NEAREST: 11, + REPEAT: 12, + RGBA: 13, + STATIC_DRAW: 14, + TEXTURE_2D: 15, + TEXTURE_MAG_FILTER: 16, + TEXTURE_MIN_FILTER: 17, + TEXTURE_WRAP_S: 18, + TEXTURE_WRAP_T: 19, + TRIANGLE_STRIP: 20, + UNSIGNED_BYTE: 21, + VERTEX_SHADER: 22, + attachShader: vi.fn(), + bindBuffer: vi.fn(), + bindTexture: vi.fn(), + bufferData: vi.fn(), + clear: vi.fn(), + clearColor: vi.fn(), + compileShader: vi.fn(), + createBuffer: vi.fn(() => ({})), + createProgram: vi.fn(() => ({})), + createShader: vi.fn(() => ({})), + createTexture: vi.fn(() => ({})), + deleteBuffer: vi.fn(), + deleteProgram: vi.fn(), + deleteShader: vi.fn(), + deleteTexture: vi.fn(), + drawArrays: vi.fn(), + enableVertexAttribArray: vi.fn(), + finish: vi.fn(), + getAttribLocation: vi.fn((_program, name: string) => + name === 'a_position' ? 0 : 1, + ), + getExtension: vi.fn(() => ({ loseContext: vi.fn() })), + getProgramInfoLog: vi.fn(() => ''), + getProgramParameter: vi.fn(() => true), + getShaderInfoLog: vi.fn(() => ''), + getShaderParameter: vi.fn(() => true), + getUniformLocation: vi.fn(() => ({})), + linkProgram: vi.fn(), + shaderSource: vi.fn(), + texImage2D: vi.fn(), + texParameterf: vi.fn(), + uniform1i: vi.fn(), + useProgram: vi.fn(), + vertexAttribPointer: vi.fn(), + viewport: vi.fn(), + } + const canvas = Object.assign(new EventTarget(), { + getContext: () => gl, + height: 0, + width: 0, + }) as unknown as HTMLCanvasElement + const restored = vi.fn() + vi.spyOn(console, 'error').mockImplementation(() => undefined) + vi.spyOn(console, 'info').mockImplementation(() => undefined) + const view = createGameView(canvas, { onContextRestored: restored }) + view.resize(540, 720, 1920, 1080, 2) + + const lost = new Event('webglcontextlost', { cancelable: true }) + canvas.dispatchEvent(lost) + expect(lost.defaultPrevented).toBe(true) + expect(view.isLost()).toBe(true) + + canvas.dispatchEvent(new Event('webglcontextrestored')) + expect(view.isLost()).toBe(false) + expect(restored).toHaveBeenCalledOnce() + expect(gl.createProgram).toHaveBeenCalledTimes(2) + expect(canvas.width).toBe(540) + expect(canvas.height).toBe(720) + + view.render() + expect(gl.drawArrays).toHaveBeenCalledOnce() + view.dispose() + }) +}) diff --git a/frontend/src/utils/gameView.ts b/frontend/src/utils/gameView.ts index 9dd0ef1..1485c98 100644 --- a/frontend/src/utils/gameView.ts +++ b/frontend/src/utils/gameView.ts @@ -31,6 +31,8 @@ export interface GameView { } export interface GameViewOptions { + onContextLost?: () => void + onContextRestored?: () => void preserveDrawingBuffer?: boolean } @@ -113,80 +115,180 @@ export function createGameView( let lost = false let disposed = false + let program: WebGLProgram | null = null + let positionBuffer: WebGLBuffer | null = null + let texcoordBuffer: WebGLBuffer | null = null + let texture: WebGLTexture | null = null + let lastSize: { + height: number + sourceHeight: number + sourceWidth: number + width: number + zoom: number + } | null = null + + const releaseResources = (): void => { + if (positionBuffer) gl.deleteBuffer(positionBuffer) + if (texcoordBuffer) gl.deleteBuffer(texcoordBuffer) + if (texture) gl.deleteTexture(texture) + if (program) gl.deleteProgram(program) + positionBuffer = null + texcoordBuffer = null + texture = null + program = null + } + + const initializeResources = (): void => { + releaseResources() + const nextProgram = gl.createProgram() + if (!nextProgram) throw new Error('game_view_program_unavailable') + const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER) + const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER) + gl.attachShader(nextProgram, vertexShader) + gl.attachShader(nextProgram, fragmentShader) + gl.linkProgram(nextProgram) + gl.deleteShader(vertexShader) + gl.deleteShader(fragmentShader) + if (!gl.getProgramParameter(nextProgram, gl.LINK_STATUS)) { + const error = gl.getProgramInfoLog(nextProgram) + gl.deleteProgram(nextProgram) + throw new Error(error || 'game_view_program_failed') + } + gl.useProgram(nextProgram) + + const positionLocation = gl.getAttribLocation(nextProgram, 'a_position') + const texcoordLocation = gl.getAttribLocation(nextProgram, 'a_texcoord') + if (positionLocation < 0 || texcoordLocation < 0) { + gl.deleteProgram(nextProgram) + throw new Error('game_view_attributes_unavailable') + } + + const nextPositionBuffer = gl.createBuffer() + const nextTexcoordBuffer = gl.createBuffer() + const nextTexture = gl.createTexture() + if (!nextPositionBuffer || !nextTexcoordBuffer || !nextTexture) { + if (nextPositionBuffer) gl.deleteBuffer(nextPositionBuffer) + if (nextTexcoordBuffer) gl.deleteBuffer(nextTexcoordBuffer) + if (nextTexture) gl.deleteTexture(nextTexture) + gl.deleteProgram(nextProgram) + throw new Error('game_view_resources_unavailable') + } + + program = nextProgram + positionBuffer = nextPositionBuffer + texcoordBuffer = nextTexcoordBuffer + texture = nextTexture + + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer) + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), + gl.DYNAMIC_DRAW, + ) + gl.enableVertexAttribArray(positionLocation) + gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0) + + gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer) + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), + gl.STATIC_DRAW, + ) + gl.enableVertexAttribArray(texcoordLocation) + gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0) + + gl.bindTexture(gl.TEXTURE_2D, texture) + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + 1, + 1, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + new Uint8Array([0, 0, 0, 255]), + ) + gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) + gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) + gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) + // CitizenFX watches this exact wrap-mode sequence and replaces the seeded pixel with the live + // game backbuffer. These calls are intentionally not redundant. + gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT) + gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT) + gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0) + gl.clearColor(0, 0, 0, 1) + } + + const applySize = (): void => { + if (!lastSize || !positionBuffer || !texcoordBuffer) return + const geometry = gameViewGeometry( + lastSize.sourceWidth, + lastSize.sourceHeight, + lastSize.width, + lastSize.height, + lastSize.zoom, + ) + canvas.width = lastSize.width + canvas.height = lastSize.height + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer) + gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.DYNAMIC_DRAW) + gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer) + gl.bufferData( + gl.ARRAY_BUFFER, + geometry.textureCoordinates, + gl.DYNAMIC_DRAW, + ) + gl.viewport(0, 0, lastSize.width, lastSize.height) + } + const onContextLost = (event: Event) => { event.preventDefault() lost = true console.error('[Camera] Game-view WebGL context lost.') + options.onContextLost?.() + } + const onContextRestored = () => { + if (disposed) return + try { + initializeResources() + lost = false + applySize() + console.info('[Camera] Game-view WebGL context restored.') + options.onContextRestored?.() + } catch (error) { + lost = true + console.error('[Camera] Could not restore the game-view WebGL context.', error) + } } canvas.addEventListener( 'webglcontextlost', onContextLost as EventListener, false, ) - - const program = gl.createProgram() - if (!program) throw new Error('game_view_program_unavailable') - gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER)) - gl.attachShader( - program, - compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER), + canvas.addEventListener( + 'webglcontextrestored', + onContextRestored as EventListener, + false, ) - gl.linkProgram(program) - if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { - throw new Error(gl.getProgramInfoLog(program) || 'game_view_program_failed') + try { + initializeResources() + } catch (error) { + canvas.removeEventListener( + 'webglcontextlost', + onContextLost as EventListener, + false, + ) + canvas.removeEventListener( + 'webglcontextrestored', + onContextRestored as EventListener, + false, + ) + releaseResources() + throw error } - gl.useProgram(program) - - const positionLocation = gl.getAttribLocation(program, 'a_position') - const texcoordLocation = gl.getAttribLocation(program, 'a_texcoord') - if (positionLocation < 0 || texcoordLocation < 0) { - throw new Error('game_view_attributes_unavailable') - } - - const positionBuffer = gl.createBuffer() - gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer) - gl.bufferData( - gl.ARRAY_BUFFER, - new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), - gl.DYNAMIC_DRAW, - ) - gl.enableVertexAttribArray(positionLocation) - gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0) - - const texcoordBuffer = gl.createBuffer() - gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer) - gl.bufferData( - gl.ARRAY_BUFFER, - new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), - gl.STATIC_DRAW, - ) - gl.enableVertexAttribArray(texcoordLocation) - gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0) - - const texture = gl.createTexture() - gl.bindTexture(gl.TEXTURE_2D, texture) - gl.texImage2D( - gl.TEXTURE_2D, - 0, - gl.RGBA, - 1, - 1, - 0, - gl.RGBA, - gl.UNSIGNED_BYTE, - new Uint8Array([0, 0, 0, 255]), - ) - gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) - gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) - gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) - // CitizenFX watches this exact wrap-mode sequence and replaces the seeded pixel with the live - // game backbuffer. These calls are intentionally not redundant. - gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) - gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT) - gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT) - gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) - gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0) - gl.clearColor(0, 0, 0, 1) return { canvas, @@ -198,11 +300,18 @@ export function createGameView( onContextLost as EventListener, false, ) + canvas.removeEventListener( + 'webglcontextrestored', + onContextRestored as EventListener, + false, + ) + if (!lost) releaseResources() gl.getExtension('WEBGL_lose_context')?.loseContext() }, isLost: () => lost, render() { - if (disposed || lost) return + if (disposed || lost || !program) return + gl.useProgram(program) gl.clear(gl.COLOR_BUFFER_BIT) gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) gl.finish() @@ -214,25 +323,15 @@ export function createGameView( sourceHeight = window.innerHeight, zoom = 1, ) { - if (disposed || lost) return - canvas.width = width - canvas.height = height - const geometry = gameViewGeometry( + lastSize = { + height, sourceWidth, sourceHeight, width, - height, zoom, - ) - gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer) - gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.DYNAMIC_DRAW) - gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer) - gl.bufferData( - gl.ARRAY_BUFFER, - geometry.textureCoordinates, - gl.DYNAMIC_DRAW, - ) - gl.viewport(0, 0, width, height) + } + if (disposed || lost) return + applySize() }, } } diff --git a/frontend/src/utils/homeLayout.test.ts b/frontend/src/utils/homeLayout.test.ts index 72602c6..e0767f8 100644 --- a/frontend/src/utils/homeLayout.test.ts +++ b/frontend/src/utils/homeLayout.test.ts @@ -5,6 +5,7 @@ import { createDefaultHomeLayout, deleteHomePage, HOME_GRID_PAGE_SIZE, + homeKeyboardTarget, MAX_HOME_GRID_PAGES, moveHomeApp, parseHomeLayout, @@ -134,6 +135,15 @@ describe('home layout', () => { expect(moved.grid[4]).toBe('notes') }) + it('provides bounded keyboard reorder targets without wrapping rows', () => { + expect(homeKeyboardTarget(defaults, 'grid', 1, 'right')).toBe(2) + expect(homeKeyboardTarget(defaults, 'grid', 3, 'right')).toBeNull() + expect(homeKeyboardTarget(defaults, 'grid', 0, 'up')).toBeNull() + expect(homeKeyboardTarget(defaults, 'grid', 0, 'down')).toBe(4) + expect(homeKeyboardTarget(defaults, 'dock', 1, 'left')).toBe(0) + expect(homeKeyboardTarget(defaults, 'dock', 1, 'down')).toBeNull() + }) + it('shifts occupied grid slots instead of replacing their apps', () => { const reordered = moveHomeApp(defaults, 'grid', 2, 'grid', 0) expect(reordered.grid.slice(0, 5)).toEqual([ diff --git a/frontend/src/utils/homeLayout.ts b/frontend/src/utils/homeLayout.ts index 01bb146..6f01235 100644 --- a/frontend/src/utils/homeLayout.ts +++ b/frontend/src/utils/homeLayout.ts @@ -1,6 +1,8 @@ import type { LaunchablePhoneAppId } from '@/types/apps' +import type { ReorderDirection } from '@/utils/keyboard' export const HOME_DOCK_CAPACITY = 4 +export const HOME_GRID_COLUMNS = 4 export const HOME_GRID_PAGE_SIZE = 20 export const MAX_HOME_GRID_PAGES = 5 @@ -304,3 +306,31 @@ export function moveHomeApp( source[sourceIndex] = insertIntoSlot(target, targetIndex, appId) return next } + +export function homeKeyboardTarget( + layout: HomeLayout, + area: HomeArea, + sourceIndex: number, + direction: ReorderDirection, +): number | null { + const source = layout[area] + if (!source[sourceIndex]) return null + + if (area === 'dock') { + if (direction !== 'left' && direction !== 'right') return null + const targetIndex = sourceIndex + (direction === 'left' ? -1 : 1) + return targetIndex >= 0 && targetIndex < source.length ? targetIndex : null + } + + const column = sourceIndex % HOME_GRID_COLUMNS + if (direction === 'left' && column === 0) return null + if (direction === 'right' && column === HOME_GRID_COLUMNS - 1) return null + const deltas: Record = { + down: HOME_GRID_COLUMNS, + left: -1, + right: 1, + up: -HOME_GRID_COLUMNS, + } + const targetIndex = sourceIndex + deltas[direction] + return targetIndex >= 0 && targetIndex < source.length ? targetIndex : null +} diff --git a/frontend/src/utils/keyboard.test.ts b/frontend/src/utils/keyboard.test.ts new file mode 100644 index 0000000..f6b1df9 --- /dev/null +++ b/frontend/src/utils/keyboard.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + consumeEscape, + handleEnterAction, + reorderDirectionFromKeyboard, +} from '@/utils/keyboard' + +describe('keyboard interaction', () => { + it('does not submit while an IME composition is active', () => { + const action = vi.fn() + const preventDefault = vi.fn() + + expect( + handleEnterAction({ isComposing: true, preventDefault }, action), + ).toBe(false) + expect(action).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + }) + + it('prevents the completed Enter key and runs its action once', () => { + const action = vi.fn() + const preventDefault = vi.fn() + + expect( + handleEnterAction({ isComposing: false, preventDefault }, action), + ).toBe(true) + expect(preventDefault).toHaveBeenCalledOnce() + expect(action).toHaveBeenCalledOnce() + }) + + it('consumes only an unhandled Escape outside IME composition', () => { + const preventDefault = vi.fn() + const stopImmediatePropagation = vi.fn() + + expect( + consumeEscape({ + defaultPrevented: false, + isComposing: false, + key: 'Escape', + preventDefault, + stopImmediatePropagation, + }), + ).toBe(true) + expect(preventDefault).toHaveBeenCalledOnce() + expect(stopImmediatePropagation).toHaveBeenCalledOnce() + + expect( + consumeEscape({ + defaultPrevented: false, + isComposing: true, + key: 'Escape', + preventDefault, + stopImmediatePropagation, + }), + ).toBe(false) + }) + + it('blocks a second Escape owner on the same event target', () => { + const target = new EventTarget() + const rootHandler = vi.fn() + target.addEventListener('keydown', (event) => { + consumeEscape(event as KeyboardEvent) + }) + target.addEventListener('keydown', rootHandler) + const event = new Event('keydown', { cancelable: true }) + Object.defineProperties(event, { + isComposing: { value: false }, + key: { value: 'Escape' }, + }) + + target.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(true) + expect(rootHandler).not.toHaveBeenCalled() + }) + + it('maps only unmodified arrow keys to reorder directions', () => { + expect( + reorderDirectionFromKeyboard({ + altKey: false, + ctrlKey: false, + isComposing: false, + key: 'ArrowLeft', + metaKey: false, + }), + ).toBe('left') + expect( + reorderDirectionFromKeyboard({ + altKey: false, + ctrlKey: true, + isComposing: false, + key: 'ArrowLeft', + metaKey: false, + }), + ).toBeNull() + }) +}) diff --git a/frontend/src/utils/keyboard.ts b/frontend/src/utils/keyboard.ts new file mode 100644 index 0000000..7d0cbbc --- /dev/null +++ b/frontend/src/utils/keyboard.ts @@ -0,0 +1,47 @@ +export type ReorderDirection = 'down' | 'left' | 'right' | 'up' + +export function consumeEscape( + event: Pick< + KeyboardEvent, + | 'defaultPrevented' + | 'isComposing' + | 'key' + | 'preventDefault' + | 'stopImmediatePropagation' + >, +): boolean { + if (event.key !== 'Escape' || event.isComposing || event.defaultPrevented) { + return false + } + event.preventDefault() + event.stopImmediatePropagation() + return true +} + +export function handleEnterAction( + event: Pick, + action: () => unknown, +): boolean { + if (event.isComposing) return false + event.preventDefault() + void action() + return true +} + +export function reorderDirectionFromKeyboard( + event: Pick< + KeyboardEvent, + 'altKey' | 'ctrlKey' | 'isComposing' | 'key' | 'metaKey' + >, +): ReorderDirection | null { + if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey) { + return null + } + const directions: Partial> = { + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', + ArrowUp: 'up', + } + return directions[event.key] ?? null +} diff --git a/frontend/src/utils/mediaRecorder.test.ts b/frontend/src/utils/mediaRecorder.test.ts new file mode 100644 index 0000000..d8b0dde --- /dev/null +++ b/frontend/src/utils/mediaRecorder.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + bindMediaRecorderError, + setBoundedMapEntry, + stopMediaRecorder, +} from '@/utils/mediaRecorder' + +class FakeRecorder extends EventTarget { + state: RecordingState = 'recording' + stop = vi.fn(() => { + this.state = 'inactive' + this.dispatchEvent(new Event('stop')) + }) +} + +describe('media recorder lifecycle', () => { + it('resolves from the recorder stop event', async () => { + const recorder = new FakeRecorder() + + await stopMediaRecorder(recorder as unknown as MediaRecorder) + + expect(recorder.stop).toHaveBeenCalledOnce() + expect(recorder.state).toBe('inactive') + }) + + it('runs recorder error cleanup only for the current generation', () => { + const staleRecorder = new FakeRecorder() + const currentRecorder = new FakeRecorder() + const cleanup = vi.fn() + let generation = 1 + const unbindStale = bindMediaRecorderError( + staleRecorder as unknown as MediaRecorder, + () => generation === 1, + cleanup, + ) + + generation = 2 + staleRecorder.dispatchEvent(new Event('error')) + expect(cleanup).not.toHaveBeenCalled() + unbindStale() + + bindMediaRecorderError( + currentRecorder as unknown as MediaRecorder, + () => generation === 2, + cleanup, + ) + currentRecorder.dispatchEvent(new Event('error')) + currentRecorder.dispatchEvent(new Event('error')) + + expect(cleanup).toHaveBeenCalledOnce() + }) + + it('keeps pending recording buffers bounded and evicts the oldest', () => { + const pending = new Map() + + setBoundedMapEntry(pending, 'first', 1, 2) + setBoundedMapEntry(pending, 'second', 2, 2) + setBoundedMapEntry(pending, 'third', 3, 2) + + expect([...pending.entries()]).toEqual([ + ['second', 2], + ['third', 3], + ]) + }) +}) diff --git a/frontend/src/utils/mediaRecorder.ts b/frontend/src/utils/mediaRecorder.ts new file mode 100644 index 0000000..87d7e04 --- /dev/null +++ b/frontend/src/utils/mediaRecorder.ts @@ -0,0 +1,62 @@ +export function bindMediaRecorderError( + recorder: MediaRecorder, + isCurrent: () => boolean, + onError: (event: Event) => void, +): () => void { + let bound = true + const handleError = (event: Event): void => { + if (!bound || !isCurrent()) return + bound = false + recorder.removeEventListener('error', handleError) + onError(event) + } + recorder.addEventListener('error', handleError) + return () => { + if (!bound) return + bound = false + recorder.removeEventListener('error', handleError) + } +} + +export async function stopMediaRecorder(recorder: MediaRecorder): Promise { + if (recorder.state === 'inactive') return + + await new Promise((resolve, reject) => { + const cleanup = (): void => { + recorder.removeEventListener('stop', onStop) + recorder.removeEventListener('error', onError) + } + const onStop = (): void => { + cleanup() + resolve() + } + const onError = (): void => { + cleanup() + reject(new Error('media_recorder_stop_failed')) + } + + recorder.addEventListener('stop', onStop, { once: true }) + recorder.addEventListener('error', onError, { once: true }) + try { + recorder.stop() + } catch (error) { + cleanup() + reject(error) + } + }) +} + +export function setBoundedMapEntry( + entries: Map, + key: Key, + value: Value, + maximumSize: number, +): void { + entries.delete(key) + entries.set(key, value) + while (entries.size > Math.max(0, maximumSize)) { + const oldest = entries.keys().next() + if (oldest.done) break + entries.delete(oldest.value) + } +} diff --git a/frontend/src/utils/musicEscape.test.ts b/frontend/src/utils/musicEscape.test.ts new file mode 100644 index 0000000..47111f2 --- /dev/null +++ b/frontend/src/utils/musicEscape.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { musicEscapeLayer } from '@/utils/musicEscape' + +const closedState = { + actionMenuOpened: false, + activeSheet: false, + addMenuOpened: false, + confirmDeletePlaylist: false, + confirmRemoveTrack: false, + playerOpened: false, +} + +describe('music Escape ownership', () => { + it('owns Escape while either music popover is open', () => { + expect( + musicEscapeLayer({ ...closedState, addMenuOpened: true }), + ).toBe('menu') + expect( + musicEscapeLayer({ ...closedState, actionMenuOpened: true }), + ).toBe('menu') + }) + + it('keeps a real form sheet above menus and the player', () => { + expect( + musicEscapeLayer({ + ...closedState, + activeSheet: true, + addMenuOpened: true, + playerOpened: true, + }), + ).toBe('sheet') + }) + + it('does not claim Escape with no music overlay open', () => { + expect(musicEscapeLayer(closedState)).toBeNull() + }) +}) diff --git a/frontend/src/utils/musicEscape.ts b/frontend/src/utils/musicEscape.ts new file mode 100644 index 0000000..b6afd04 --- /dev/null +++ b/frontend/src/utils/musicEscape.ts @@ -0,0 +1,22 @@ +export type MusicEscapeLayer = + | 'delete-playlist-confirmation' + | 'menu' + | 'player' + | 'remove-track-confirmation' + | 'sheet' + +export function musicEscapeLayer(state: { + actionMenuOpened: boolean + activeSheet: boolean + addMenuOpened: boolean + confirmDeletePlaylist: boolean + confirmRemoveTrack: boolean + playerOpened: boolean +}): MusicEscapeLayer | null { + if (state.confirmRemoveTrack) return 'remove-track-confirmation' + if (state.confirmDeletePlaylist) return 'delete-playlist-confirmation' + if (state.activeSheet) return 'sheet' + if (state.addMenuOpened || state.actionMenuOpened) return 'menu' + if (state.playerOpened) return 'player' + return null +} diff --git a/frontend/src/utils/nui.test.ts b/frontend/src/utils/nui.test.ts new file mode 100644 index 0000000..39dc5e2 --- /dev/null +++ b/frontend/src/utils/nui.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { nuiCall } from '@/utils/nui' + +describe('nuiCall', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('window', { + clearTimeout: globalThis.clearTimeout, + location: { search: '' }, + setTimeout: globalThis.setTimeout, + }) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('clears the request timeout after a successful callback', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: { value: 1 }, success: true }), { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(nuiCall<{ value: number }>('test')).resolves.toEqual({ + data: { value: 1 }, + success: true, + }) + expect(vi.getTimerCount()).toBe(0) + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:3002/api/test', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ) + }) + + it('aborts a callback that never completes', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')) + }) + }), + ), + ) + + const request = nuiCall('never-responds') + await vi.advanceTimersByTimeAsync(20_000) + + await expect(request).resolves.toEqual({ + error: 'request_timeout', + success: false, + }) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/frontend/src/utils/nui.ts b/frontend/src/utils/nui.ts index 22ecce7..eecee7e 100644 --- a/frontend/src/utils/nui.ts +++ b/frontend/src/utils/nui.ts @@ -1,4 +1,5 @@ const resourceName = globalThis.window?.GetParentResourceName?.() ?? 'sky_phone' +const requestTimeoutMs = 20_000 export type NuiResponse = { success: boolean @@ -24,12 +25,15 @@ export async function nuiCall( undefined, } : data + const controller = new AbortController() + const timeoutId = window.setTimeout(() => controller.abort(), requestTimeoutMs) try { const response = await fetch(`${baseUrl}/${endpoint}`, { body: JSON.stringify(requestData), headers: { 'Content-Type': 'application/json' }, method: 'POST', + signal: controller.signal, }) if (!response.ok) { @@ -41,8 +45,14 @@ export async function nuiCall( const body = await response.text() return body ? (JSON.parse(body) as NuiResponse) : { success: true } } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' + const message = controller.signal.aborted + ? 'request_timeout' + : error instanceof Error + ? error.message + : 'Unknown error' console.error(`[NUI] ${endpoint} failed:`, error) return { error: message, success: false } + } finally { + window.clearTimeout(timeoutId) } } diff --git a/frontend/src/utils/preferences.test.ts b/frontend/src/utils/preferences.test.ts index 9ddfb58..5084e7d 100644 --- a/frontend/src/utils/preferences.test.ts +++ b/frontend/src/utils/preferences.test.ts @@ -85,6 +85,17 @@ describe('preferences', () => { expect(value.settings.screenBrightness).toBe(10) }) + it('keeps the phone above the minimum usable scale', () => { + const value = parsePhonePreferences( + JSON.stringify({ + version: 1, + settings: { phoneScale: 50 }, + }), + ) + + expect(value.settings.phoneScale).toBe(75) + }) + it('preserves safe notification preferences for custom apps', () => { const appId = 'example-app' as LaunchablePhoneAppId const value = parsePhonePreferences( diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 7ef0550..c586705 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -23,7 +23,7 @@ export const PHONE_FRAME_IDS = [ export const RINGTONE_IDS = ['skyline', 'horizon', 'pulse'] as const export const NOTIFICATION_SOUND_IDS = ['chime', 'signal', 'soft'] as const export const WALLPAPER_IDS = ['midnight', 'aurora', 'ember'] as const -export const PHONE_SCALE_MIN = 50 +export const PHONE_SCALE_MIN = 75 export const PHONE_SCALE_MAX = 150 export const PHONE_SCALE_STEP = 5 @@ -197,6 +197,10 @@ export function ensureAppNotificationPreferences( } } +export function clampPhoneScale(value: number): number { + return Math.min(PHONE_SCALE_MAX, Math.max(PHONE_SCALE_MIN, value)) +} + export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 { if (!raw) return cloneJsonData(DEFAULT_PHONE_PREFERENCES) @@ -249,11 +253,13 @@ export function parsePhonePreferences(raw: string | null): PhonePreferencesV1 { 100, ), notifications: readNotifications(settings.notifications), - phoneScale: readNumber( - settings.phoneScale, - defaults.phoneScale, - PHONE_SCALE_MIN, - PHONE_SCALE_MAX, + phoneScale: clampPhoneScale( + readNumber( + settings.phoneScale, + defaults.phoneScale, + Number.MIN_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER, + ), ), ringtone: readChoice( settings.ringtone, diff --git a/frontend/src/utils/widgetLayout.test.ts b/frontend/src/utils/widgetLayout.test.ts index aac1d6f..38f80a3 100644 --- a/frontend/src/utils/widgetLayout.test.ts +++ b/frontend/src/utils/widgetLayout.test.ts @@ -9,6 +9,7 @@ import { removeWidget, resizeWidget, widgetOccupiedCells, + widgetKeyboardTarget, } from '@/utils/widgetLayout' describe('widget layout', () => { @@ -47,6 +48,21 @@ describe('widget layout', () => { expect(next.instances).toHaveLength(layout.instances.length) }) + it('provides bounded keyboard targets for each widget size', () => { + const layout = createDefaultWidgetLayout() + const clock = layout.instances.find( + (instance) => instance.id === 'home-clock', + )! + const music = layout.instances.find( + (instance) => instance.id === 'home-music', + )! + + expect(widgetKeyboardTarget(clock, 'right')).toEqual({ column: 1, row: 0 }) + expect(widgetKeyboardTarget(clock, 'up')).toBeNull() + expect(widgetKeyboardTarget(music, 'right')).toBeNull() + expect(widgetKeyboardTarget(music, 'down')).toEqual({ column: 0, row: 3 }) + }) + it('allows a small widget in the center with app cells on both sides', () => { const layout = createDefaultWidgetLayout() const moved = moveWidget(layout, 'home-clock', 2, 1, 1) diff --git a/frontend/src/utils/widgetLayout.ts b/frontend/src/utils/widgetLayout.ts index 71182be..5b6dc54 100644 --- a/frontend/src/utils/widgetLayout.ts +++ b/frontend/src/utils/widgetLayout.ts @@ -6,6 +6,7 @@ import type { WidgetSettings, WidgetSize, } from '@/types/widgets' +import type { ReorderDirection } from '@/utils/keyboard' export const WIDGET_GRID_COLUMNS = 4 export const WIDGET_HOME_ROWS = 5 @@ -296,6 +297,32 @@ export function moveWidget( return { instances: placed, version: 1 } } +export function widgetKeyboardTarget( + instance: WidgetInstance, + direction: ReorderDirection, +): { column: number; row: number } | null { + const span = WIDGET_SPANS[instance.size] + const maximumColumn = WIDGET_GRID_COLUMNS - span.columns + const maximumRow = rowsForPage(instance.page) - span.rows + const target = { + column: + instance.column + + (direction === 'left' ? -1 : direction === 'right' ? 1 : 0), + row: + instance.row + + (direction === 'up' ? -1 : direction === 'down' ? 1 : 0), + } + if ( + target.column < 0 || + target.column > maximumColumn || + target.row < 0 || + target.row > maximumRow + ) { + return null + } + return target +} + export function resizeWidget( layout: WidgetLayout, id: string, diff --git a/frontend/src/views/SpringboardView.vue b/frontend/src/views/SpringboardView.vue index aa02bf4..03b7cb0 100644 --- a/frontend/src/views/SpringboardView.vue +++ b/frontend/src/views/SpringboardView.vue @@ -17,13 +17,16 @@ import type { WidgetKind, WidgetSettings, WidgetSize } from '@/types/widgets' import { deleteHomePage as previewHomePageDelete, HOME_GRID_PAGE_SIZE, + homeKeyboardTarget, MAX_HOME_GRID_PAGES, type HomeArea, } from '@/utils/homeLayout' +import type { ReorderDirection } from '@/utils/keyboard' import { deleteWidgetPage as previewWidgetPageDelete, moveWidget as previewWidgetMove, WIDGET_GRID_COLUMNS, + widgetKeyboardTarget, widgetOccupiedCells, } from '@/utils/widgetLayout' @@ -515,6 +518,14 @@ function stopWidgetDrag(): void { clearWidgetDragPreview() } +function reorderWidget(id: string, direction: ReorderDirection): void { + const instance = widgets.layout.instances.find((widget) => widget.id === id) + if (!instance) return + const target = widgetKeyboardTarget(instance, direction) + if (!target) return + widgets.move(id, instance.page, target.column, target.row) +} + function removeWidget(id: string): void { widgets.remove(id) if (widgetActionId.value === id) widgetActionId.value = null @@ -620,6 +631,21 @@ function stopHomeDrag(): void { draggingHomeApp.value = null } +function reorderHomeApp( + area: HomeArea, + sourceIndex: number, + direction: ReorderDirection, +): void { + const targetIndex = homeKeyboardTarget( + appStore.homeLayout, + area, + sourceIndex, + direction, + ) + if (targetIndex === null) return + appStore.moveHomeApp(area, sourceIndex, area, targetIndex) +} + async function addHomePage(): Promise { if (addingHomePage.value) return addingHomePage.value = true @@ -704,6 +730,7 @@ watch(isEditablePage, (visible) => { @dragstart="startWidgetDrag" @menu="openWidgetMenu" @remove="removeWidget" + @reorder="reorderWidget" /> @@ -725,6 +752,7 @@ watch(isEditablePage, (visible) => { @dragstart="startWidgetDrag" @menu="openWidgetMenu" @remove="removeWidget" + @reorder="reorderWidget" />
{ @dragstart="startHomeDrag('dock', appIndex)" @edit="enterEditMode" @remove="removeHomeApp(app.id)" + @reorder="reorderHomeApp('dock', appIndex, $event)" />
{ - -
-

- {{ - activeWidget - ? phone.t(`Home.widgetSystem.${activeWidget.kind}.name`) - : phone.t('Home.widgets.label') - }} -

- - - - - - - - - + - {{ phone.t('Common.cancel') }} - - +
+

+ {{ + activeWidget + ? phone.t(`Home.widgetSystem.${activeWidget.kind}.name`) + : phone.t('Home.widgets.label') + }} +

+ + + + + + + + + + {{ phone.t('Common.cancel') }} + + +
{ diff --git a/frontend/src/views/apps/FlareApp.vue b/frontend/src/views/apps/FlareApp.vue index 6eda1a4..b3c6abc 100644 --- a/frontend/src/views/apps/FlareApp.vue +++ b/frontend/src/views/apps/FlareApp.vue @@ -85,6 +85,7 @@ import type { PhoneMedia } from '@/types/media' import type { EasySharePayload } from '@/types/easyshare' import type { GifSearchResult, SmsAttachmentType } from '@/types/messages' import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date' +import { handleEnterAction } from '@/utils/keyboard' type FlareTab = 'discover' | 'explore' | 'likes' | 'matches' | 'profile' type ExploreMode = 'all' | 'dates' | 'friends' | 'longTerm' @@ -1145,7 +1146,7 @@ onBeforeUnmount(() => { :value="draft" :disabled="flare.sending" @input="draft = eventValue($event)" - @keydown.enter.exact.prevent="sendMessage" + @keydown.enter.exact="handleEnterAction($event, sendMessage)" >
-
+ +
-
+
+
{ >{{ t('cancel') }} -
- +
+
+
+

{{ t('report') }}

@@ -1703,14 +1769,14 @@ onBeforeUnmount(() => { t('cancel') }}
- - + +
+
+

{{ t('chooseSound') }}

@@ -1753,14 +1819,14 @@ onBeforeUnmount(() => { t('cancel') }}
- -
+ +
+
+

{{ t('whoCanWatch') }}

{ >{{ t('cancel') }} -
-
+
+
+
+

{{ t('accountType') }}

{ >{{ t('cancel') }} -
+
+
{ rgba(0, 0, 0, 0.74) ); } +.video-playback-fallback { + position: absolute; + z-index: 10; + top: 50%; + left: 50%; + width: min(230px, 72%); + display: grid; + justify-items: center; + gap: 7px; + border: 1px solid rgb(255 255 255 / 24%); + border-radius: 18px; + padding: 16px; + background: rgb(18 18 20 / 88%); + color: #fff; + text-align: center; + transform: translate(-50%, -50%); +} +.video-playback-fallback svg { + width: 28px; + height: 28px; + color: #ff9f0a; +} +.video-playback-fallback strong { + font-size: 12px; +} +.video-playback-fallback span { + color: #64a8ff; + font-size: 11px; + font-weight: 700; +} .video-copy { position: absolute; left: 13px; @@ -2623,7 +2720,7 @@ onBeforeUnmount(() => { .done-button { font-weight: 650; } -.fliptok-sheet { +.fliptok-sheet :deep(.k-sheet) { color: #f5f5f7; } .sheet-handle { diff --git a/frontend/src/views/apps/GalleryApp.vue b/frontend/src/views/apps/GalleryApp.vue index 0539d21..c993d3f 100644 --- a/frontend/src/views/apps/GalleryApp.vue +++ b/frontend/src/views/apps/GalleryApp.vue @@ -84,10 +84,13 @@ const imageZoom = ref(1) const imagePan = ref({ x: 0, y: 0 }) const landscapeViewer = ref(false) const dragging = ref(false) +const videoPlaybackError = ref(false) const dragStart = ref({ panX: 0, panY: 0, x: 0, y: 0 }) let observer: IntersectionObserver | null = null let toastTimer: number | undefined let pendingDeleteCorrelation = '' +let dragTarget: HTMLElement | null = null +let dragPointerId: number | null = null const imageStyle = computed(() => ({ cursor: @@ -227,6 +230,7 @@ function openMedia(entry: PhoneMedia): void { landscapeViewer.value = false phone.setCameraLandscape(false) selected.value = entry + videoPlaybackError.value = false imageZoom.value = 1 imagePan.value = { x: 0, y: 0 } } @@ -254,6 +258,7 @@ function closeMedia(): void { landscapeViewer.value = false phone.setCameraLandscape(false) selected.value = null + videoPlaybackError.value = false deleteDialogOpened.value = false stopDragging() } @@ -295,6 +300,9 @@ function startDragging(event: PointerEvent): void { setZoom(2) return } + dragTarget = event.currentTarget as HTMLElement + dragPointerId = event.pointerId + dragTarget.setPointerCapture(event.pointerId) dragging.value = true dragStart.value = { panX: imagePan.value.x, @@ -302,8 +310,6 @@ function startDragging(event: PointerEvent): void { x: event.clientX, y: event.clientY, } - window.addEventListener('pointermove', moveImage) - window.addEventListener('pointerup', stopDragging) } function moveImage(event: PointerEvent): void { @@ -316,8 +322,44 @@ function moveImage(event: PointerEvent): void { function stopDragging(): void { dragging.value = false - window.removeEventListener('pointermove', moveImage) - window.removeEventListener('pointerup', stopDragging) + if ( + dragTarget && + dragPointerId !== null && + dragTarget.hasPointerCapture(dragPointerId) + ) { + dragTarget.releasePointerCapture(dragPointerId) + } + dragTarget = null + dragPointerId = null +} + +function moveImageWithKeyboard(event: KeyboardEvent): void { + if (imageZoom.value <= 1) return + const step = event.shiftKey ? 48 : 24 + const offsets: Partial> = { + ArrowDown: { x: 0, y: -step }, + ArrowLeft: { x: step, y: 0 }, + ArrowRight: { x: -step, y: 0 }, + ArrowUp: { x: 0, y: step }, + } + const offset = offsets[event.key] + if (!offset) return + event.preventDefault() + event.stopPropagation() + imagePan.value = { + x: imagePan.value.x + offset.x, + y: imagePan.value.y + offset.y, + } +} + +async function initializeVideo(event: Event): Promise { + orientToMedia(event) + videoPlaybackError.value = false + try { + await (event.currentTarget as HTMLVideoElement).play() + } catch { + // The native controls remain visible when embedded CEF blocks autoplay. + } } async function deleteSelected(): Promise { @@ -569,18 +611,33 @@ onBeforeUnmount(() => { :alt="phone.t('Apps.photos.photoAlt')" :style="imageStyle" draggable="false" + tabindex="0" @load="orientToMedia" @pointerdown="startDragging" + @pointermove="moveImage" + @pointerup="stopDragging" + @pointercancel="stopDragging" + @lostpointercapture="stopDragging" + @keydown="moveImageWithKeyboard" @dblclick="setZoom(imageZoom === 1 ? 2 : 1)" /> + + {{ phone.t('Apps.photos.errors.unsupported') }} +
@@ -776,6 +833,8 @@ onBeforeUnmount(() => { position: absolute; top: 50%; left: 50%; + width: 720px; + height: 368px; width: 100cqh; height: 100cqw; transform: translate(-50%, -50%) rotate(90deg); diff --git a/frontend/src/views/apps/GarageApp.vue b/frontend/src/views/apps/GarageApp.vue index fde43b0..9bb31c1 100644 --- a/frontend/src/views/apps/GarageApp.vue +++ b/frontend/src/views/apps/GarageApp.vue @@ -395,11 +395,11 @@ onBeforeUnmount(() => {

- +
+
{ {{ phone.t('Apps.easyShare.share') }}
-
+ +
{
- +
+
{ maxlength="40" outline @input="updateMarkerLabel" - @keydown.enter="saveMarker" + @keydown.enter="handleEnterAction($event, saveMarker)" /> {{ @@ -739,7 +740,8 @@ onBeforeUnmount(() => {
-
+ +
{{ toastText }} diff --git a/frontend/src/views/apps/MessagesApp.vue b/frontend/src/views/apps/MessagesApp.vue index d401732..bae7e1f 100644 --- a/frontend/src/views/apps/MessagesApp.vue +++ b/frontend/src/views/apps/MessagesApp.vue @@ -54,6 +54,7 @@ import { useMessagesStore } from '@/stores/messages' import { useMessageMediaStore } from '@/stores/messageMedia' import { usePhoneStore } from '@/stores/phone' import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date' +import { handleEnterAction } from '@/utils/keyboard' import { sortContactsByMessageRecency } from '@/utils/messages' import type { GifSearchResult, @@ -1496,7 +1497,7 @@ onBeforeUnmount(() => { :value="draft" :disabled="sending" @input="draft = eventValue($event)" - @keydown.enter.exact.prevent="sendTextMessage" + @keydown.enter.exact="handleEnterAction($event, sendTextMessage)" > - -
+ + +
- -
+
+ +
{

{{ phone.t('Apps.music.errors.playback_failed') }}

-
-
+
+
+
{ .music-form-sheet, .music-player-sheet { --music-accent: #fa2d48; + display: contents; color: var(--music-label); } @@ -1985,7 +2017,7 @@ onBeforeUnmount(() => { color: #ff453a !important; } -.music-player-sheet { +.music-player-sheet :deep(.k-sheet) { background: rgb(22 22 25 / 96%) !important; } diff --git a/frontend/src/views/apps/NotesApp.vue b/frontend/src/views/apps/NotesApp.vue index 2a6c55b..a4c600e 100644 --- a/frontend/src/views/apps/NotesApp.vue +++ b/frontend/src/views/apps/NotesApp.vue @@ -50,7 +50,8 @@ const deleteActionColors = { textMaterial: 'text-red-500', } const noteBodyStyle: CSSProperties = { - height: 'calc(100cqh - 210px)', + height: '617px', + maxHeight: 'calc(100% - 210px)', resize: 'none', } const currentNote = computed(() => diff --git a/frontend/src/views/apps/NumberMergeApp.vue b/frontend/src/views/apps/NumberMergeApp.vue index c1d6cbf..897c649 100644 --- a/frontend/src/views/apps/NumberMergeApp.vue +++ b/frontend/src/views/apps/NumberMergeApp.vue @@ -348,6 +348,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) display: flex; align-items: center; justify-content: space-between; + gap: 10px; +} + +.number-merge-header > div { + min-width: 0; } .number-merge-header span { @@ -357,6 +362,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) font-weight: 800; letter-spacing: 1.2px; text-transform: uppercase; + overflow-wrap: anywhere; } .number-merge-header h1 { @@ -364,6 +370,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) font-size: 28px; line-height: 1; letter-spacing: -1px; + overflow-wrap: anywhere; } .number-merge-header button, @@ -383,11 +390,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) .number-merge-menu { height: calc(100% - 55px); + min-height: 0; display: flex; flex-direction: column; align-items: center; - justify-content: center; + justify-content: flex-start; gap: 13px; + overflow-y: auto; + padding: 12px 0; text-align: center; } @@ -401,6 +411,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) border-radius: 29px; background: #7e5147; box-shadow: 0 17px 30px rgb(93 48 36 / 20%); + flex: 0 0 auto; + margin-top: auto; transform: rotate(-2deg); } @@ -418,8 +430,9 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) .number-merge-hero__tile--4 { color: #fff7e4; background: #e96c2c; } .number-merge-hero__tile--8 { color: #fff7e4; background: #713c31; } -.number-merge-menu__intro h2 { margin: 0; font-size: 25px; letter-spacing: -0.5px; } -.number-merge-menu__intro p { max-width: 300px; margin: 6px 0 0; color: #8b675b; font-size: 14px; line-height: 1.45; } +.number-merge-menu__intro { width: 100%; } +.number-merge-menu__intro h2 { margin: 0; font-size: 25px; letter-spacing: -0.5px; overflow-wrap: anywhere; } +.number-merge-menu__intro p { max-width: 300px; margin: 6px auto 0; color: #8b675b; font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; } .number-merge-records { width: 100%; @@ -429,6 +442,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) } .number-merge-records div { + min-width: 0; display: grid; gap: 1px; padding: 8px; @@ -443,9 +457,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) font-size: 11px; font-weight: 800; text-transform: uppercase; + overflow-wrap: anywhere; } -.number-merge-records strong { font-size: 20px; } +.number-merge-records strong { min-width: 0; overflow-wrap: anywhere; font-size: 20px; } .number-merge-menu__actions { width: 100%; display: grid; gap: 7px; } @@ -453,11 +468,12 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) .number-merge-secondary, .number-merge-danger { min-height: 46px; - padding: 0 18px; + padding: 10px 18px; border-radius: 14px; font-size: 15px; font-weight: 850; cursor: pointer; + overflow-wrap: anywhere; } .number-merge-primary { @@ -474,14 +490,17 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) } .number-merge-how-to { + width: 100%; + flex: 0 0 auto; + margin-bottom: auto; padding: 9px 15px; border-radius: 14px; color: #795549; background: rgb(255 255 255 / 28%); } -.number-merge-how-to strong { font-size: 13px; text-transform: uppercase; } -.number-merge-how-to p { margin: 5px 0 0; font-size: 12px; line-height: 1.4; } +.number-merge-how-to strong { font-size: 13px; text-transform: uppercase; overflow-wrap: anywhere; } +.number-merge-how-to p { margin: 5px 0 0; font-size: 12px; line-height: 1.4; overflow-wrap: anywhere; } .number-merge-how-to div { margin: 7px 0 4px; color: #a7472d; @@ -489,7 +508,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) font-weight: 900; letter-spacing: -0.2px; } -.number-merge-how-to small { display: block; color: #8b6559; font-size: 11px; line-height: 1.35; } +.number-merge-how-to small { display: block; color: #8b6559; font-size: 11px; line-height: 1.35; overflow-wrap: anywhere; } .number-merge-game { position: absolute; @@ -629,7 +648,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) display: flex; flex-direction: column; align-items: center; - justify-content: center; + justify-content: flex-start; gap: 8px; padding: 22px; border-radius: 20px; @@ -637,12 +656,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) backdrop-filter: blur(5px); color: #fff7ea; text-align: center; + overflow-y: auto; } -.number-merge-overlay > span { color: #ffc75b; font-size: 29px; font-weight: 900; } -.number-merge-overlay h2 { margin: 0; font-size: 25px; } -.number-merge-overlay p { margin: -3px 0 4px; color: #e7cabd; font-size: 14px; line-height: 1.4; } -.number-merge-overlay button { min-width: 160px; } +.number-merge-overlay > span { margin-top: auto; color: #ffc75b; font-size: 29px; font-weight: 900; } +.number-merge-overlay h2 { margin: 0; font-size: 25px; overflow-wrap: anywhere; } +.number-merge-overlay p { margin: -3px 0 4px; color: #e7cabd; font-size: 14px; line-height: 1.4; overflow-wrap: anywhere; } +.number-merge-overlay button { min-width: min(160px, 100%); } +.number-merge-overlay .number-merge-link { margin-bottom: auto; } .number-merge-overlay .number-merge-secondary { color: #fff1df; background: rgb(255 255 255 / 8%); border-color: rgb(255 255 255 / 13%); } .number-merge-link { @@ -677,6 +698,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) inset: 0; display: grid; place-items: center; + min-height: 0; + overflow-y: auto; padding: 30px; background: rgb(54 29 25 / 54%); backdrop-filter: blur(6px); @@ -684,6 +707,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) .number-merge-confirm > div { width: 100%; + max-height: calc(100% - 32px); + overflow-y: auto; display: grid; gap: 9px; padding: 21px; @@ -694,8 +719,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) text-align: center; } -.number-merge-confirm h2 { margin: 0; font-size: 21px; } -.number-merge-confirm p { margin: 0 0 5px; color: #876359; font-size: 14px; line-height: 1.4; } +.number-merge-confirm h2 { margin: 0; font-size: 21px; overflow-wrap: anywhere; } +.number-merge-confirm p { margin: 0 0 5px; color: #876359; font-size: 14px; line-height: 1.4; overflow-wrap: anywhere; } .number-merge-danger { border: 0; color: #fff; background: #b64f39; } button:active { transform: scale(0.97); } diff --git a/frontend/src/views/apps/PhoneApp.vue b/frontend/src/views/apps/PhoneApp.vue index e0461be..989cf2f 100644 --- a/frontend/src/views/apps/PhoneApp.vue +++ b/frontend/src/views/apps/PhoneApp.vue @@ -1456,11 +1456,11 @@ onBeforeUnmount(() => { - +
+
{
-
+ + { color: rgba(255, 255, 255, 0.62); } -.phone-contact-editor-sheet { +.phone-contact-editor-sheet :deep(.k-sheet) { width: 100%; height: calc(100% - 52px); max-height: calc(100% - 52px); @@ -3729,7 +3730,7 @@ onBeforeUnmount(() => { box-shadow: inset 0 0 0 1px rgba(142, 142, 147, 0.18); } - .phone-recent-row:has(button:hover), + .phone-recent-row:hover, .phone-my-card:hover, .phone-contact-row:hover { border-radius: 14px; diff --git a/frontend/src/views/apps/PicstagramApp.vue b/frontend/src/views/apps/PicstagramApp.vue index dc7a6d6..96070a0 100644 --- a/frontend/src/views/apps/PicstagramApp.vue +++ b/frontend/src/views/apps/PicstagramApp.vue @@ -2852,4 +2852,10 @@ onBeforeUnmount(() => { transform: scale(1.28); } } + +@supports not (color: color-mix(in srgb, white, black)) { + .ps-activity--unread { + background: rgb(10 132 255 / 10%); + } +} diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index f2b77f8..f2e8742 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -7,6 +7,22 @@ const port = Number(process.argv[2]) || 3001 app.use(cors()) app.use(express.json()) +const lifecycleEndpoints = new Set([ + 'camera:setActive', + 'camera:setFacing', + 'camera:setFlash', + 'camera:setFocus', + 'camera:setOrientation', + 'camera:setZoom', + 'close', + 'custom-app:lifecycle', + 'device:notification-open', + 'notification:focus', + 'sim:picker-close', + 'ui:opened', + 'ui:ready', +]) + function calendarTime(dayOffset, hour, minute = 0) { const value = new Date() value.setDate(value.getDate() + dayOffset) @@ -2088,6 +2104,38 @@ const deviceData = { }, revision: 2, }, + notifications: { + payload: { + items: [ + { + appId: 'messages', + id: 'demo-notification-message', + route: '/apps/messages?phoneNumber=5551110001', + subtitle: 'Alex Rivera', + text: 'Meet us at the observatory after sunset.', + title: 'Messages', + }, + { + appId: 'companies', + id: 'demo-notification-company', + route: '/apps/companies?area=requests', + subtitle: 'Los Santos Customs', + text: 'Your repair request has been accepted.', + title: 'Companies', + }, + { + appId: 'billing', + id: 'demo-notification-billing', + route: '/apps/billing', + subtitle: 'Los Santos Customs', + text: 'A new invoice for $1,850 is ready.', + title: 'Billing', + }, + ], + version: 1, + }, + revision: 1, + }, settings: { payload: { settings: { @@ -2110,6 +2158,14 @@ const deviceData = { } let mockPasscode = '' let mockSecurity = { enabled: false, length: null, lockedUntil: 0 } +let mockSim = { + id: 'development-sim', + number: '5551234567', + removable: true, + registered: true, + type: 'registered', +} +let mockPayphoneCall = null const blockedCallNumbers = new Set() let recentCalls = [ { @@ -4049,9 +4105,12 @@ function easyShareHistoryForScenario(testScenario) { } app.post('/api/:endpoint', (request, response) => { - console.log(`[NUI] ${request.params.endpoint}`, request.body) const endpoint = request.params.endpoint const testScenario = String(request.body._testScenario ?? '') + if (lifecycleEndpoints.has(endpoint)) { + response.json({ success: true }) + return + } if (endpoint.startsWith('companies:') && testScenario === 'companies-error') { response.json({ success: false, error: 'service_unavailable' }) return @@ -5923,6 +5982,47 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: flipTokActivities }) return } + if (endpoint === 'fliptok:mark-activities') { + const readAt = new Date().toISOString() + flipTokActivities = flipTokActivities.map((activity) => ({ + ...activity, + read_at: activity.read_at ?? readAt, + })) + response.json({ success: true }) + return + } + if (endpoint === 'fliptok:view') { + const video = flipTokVideos.find((item) => item.id === request.body.id) + if (!video) { + response.json({ success: false, error: 'video_not_found' }) + return + } + video.view_count += 1 + response.json({ success: true }) + return + } + if (endpoint === 'fliptok:report') { + const video = flipTokVideos.find((item) => item.id === request.body.id) + if (!video) { + response.json({ success: false, error: 'video_not_found' }) + return + } + flipTokReports.push({ + caption: video.caption, + created_at: Date.now(), + creator_display_name: video.display_name, + creator_handle: video.handle, + details: String(request.body.details ?? ''), + id: `report-${Date.now()}`, + reason: request.body.reason, + reporter_display_name: flipTokProfile.display_name, + reporter_handle: flipTokProfile.handle, + url: video.url, + video_id: video.id, + }) + response.json({ success: true }) + return + } if (endpoint === 'fliptok:profile') { const profileId = Number(request.body.profileId || 0) const handle = String(request.body.handle || '').toLowerCase() @@ -7161,13 +7261,7 @@ app.post('/api/:endpoint', (request, response) => { : deviceData, imei: '356938035643809', name: 'Personal iFruit Phone', - sim: { - id: 'development-sim', - number: '5551234567', - removable: true, - registered: true, - type: 'registered', - }, + sim: mockSim, }, notes: mockNotes, security: mockSecurity, @@ -7272,7 +7366,7 @@ app.post('/api/:endpoint', (request, response) => { : messageType === 'gif' ? 'image/gif' : messageType === 'video' - ? 'video/mp4' + ? 'video/webm' : null, media_payload: messageType === 'voice' @@ -7497,6 +7591,15 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: { revision } }) return } + if (endpoint === 'notifications:save') { + const revision = (deviceData.notifications?.revision ?? 0) + 1 + deviceData.notifications = { + payload: request.body.payload, + revision, + } + response.json({ success: true, data: { revision } }) + return + } if (endpoint === 'security:unlock') { response.json( !mockSecurity.enabled || request.body.passcode === mockPasscode @@ -7929,16 +8032,29 @@ app.post('/api/:endpoint', (request, response) => { return } if (endpoint === 'marketplace:profile-save') { + const displayName = String(request.body.displayName ?? '').trim() + const bio = String(request.body.bio ?? '').trim() const avatarMediaId = Number(request.body.avatarMediaId) const avatar = mockMedia.find( (item) => item.id === avatarMediaId && item.mediaType === 'photo', ) + if ( + displayName.length < 2 || + displayName.length > 40 || + bio.length > 160 || + !Number.isInteger(avatarMediaId) || + avatarMediaId < 0 || + (avatarMediaId > 0 && !avatar) + ) { + response.json({ success: false, error: 'invalid_profile' }) + return + } marketplaceProfile = { ...marketplaceProfile, avatar_media_id: avatarMediaId || null, avatar_url: avatar?.url ?? null, - bio: String(request.body.bio ?? '').trim(), - display_name: String(request.body.displayName ?? '').trim(), + bio, + display_name: displayName, exists: true, } response.json({ success: true, data: marketplaceProfile }) @@ -8245,6 +8361,61 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: false, error: 'confirmation_required' }) return } + if (endpoint === 'sim:insert') { + mockSim = { + id: `development-sim-${request.body.imei}`, + number: + request.body.imei === '356938035643810' ? '5559876543' : '5551234567', + removable: true, + registered: true, + type: 'registered', + } + response.json({ success: true }) + return + } + if (endpoint === 'sim:eject') { + if (!mockSim) { + response.json({ success: false, error: 'no_sim' }) + return + } + mockSim = null + response.json({ success: true }) + return + } + if (endpoint === 'payphone:dial') { + const phoneNumber = String(request.body.phoneNumber ?? '').replace( + /\D/g, + '', + ) + if (phoneNumber.length !== 10) { + response.json({ success: false, error: 'invalid_number' }) + return + } + if (phoneNumber === '5550000000') { + response.json({ success: false, error: 'busy' }) + return + } + mockPayphoneCall = { + answeredAt: Math.floor(Date.now() / 1000), + elapsedSeconds: 0, + id: `payphone-${Date.now()}`, + otherNumber: phoneNumber, + state: 'connected', + totalCost: 0, + } + response.json({ success: true, data: mockPayphoneCall }) + return + } + if (endpoint === 'payphone:hangup') { + mockPayphoneCall = null + response.json({ success: true }) + return + } + if (endpoint === 'payphone:close') { + mockPayphoneCall = null + response.json({ success: true }) + return + } if (endpoint === 'notes:list') { response.json({ success: true, data: mockNotes }) return @@ -8384,9 +8555,14 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true }) return } - response.json({ success: true }) + console.error(`[NUI] Missing browser mock for ${endpoint}`) + response.json({ success: false, error: 'mock_endpoint_missing' }) }) -app.listen(port, () => { - console.log(`Mock NUI server listening on http://localhost:${port}`) -}) +if (require.main === module) { + app.listen(port, () => { + console.log(`Mock NUI server listening on http://localhost:${port}`) + }) +} + +module.exports = { app } diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs new file mode 100644 index 0000000..46897c5 --- /dev/null +++ b/frontend/testserver/smoke.cjs @@ -0,0 +1,354 @@ +const assert = require('node:assert/strict') +const { once } = require('node:events') + +const { app } = require('./index.cjs') + +const browserDataRequests = [ + ['development:bootstrap', {}], + ['account:devices', {}], + ['banking:overview', {}], + ['billing:overview', {}], + ['billing:list', { filter: 'all', limit: 20, offset: 0 }], + ['calendar:list', { endsAt: 4_102_444_800, startsAt: 0 }], + ['calls:recents', {}], + ['companies:list', {}], + ['companies:my-requests', { limit: 20, offset: 0 }], + ['companies:work-context', {}], + ['companies:work-queue', { limit: 20, offset: 0 }], + ['contacts:list', {}], + ['crewlink:bootstrap', {}], + ['crewlink:live', {}], + ['crewlink:nearby', {}], + ['darkchat:bootstrap', {}], + ['easyshare:bootstrap', {}], + ['easyshare:own-contact', {}], + ['feather:bootstrap', {}], + ['feather:feed', { limit: 20 }], + ['feather:explore', { limit: 20 }], + ['flare:bootstrap', {}], + ['fliptok:bootstrap', {}], + ['fliptok:feed', { limit: 20 }], + ['fliptok:discover', { limit: 20 }], + ['fliptok:activities', {}], + ['gallery:list', {}], + ['garage:vehicles', {}], + ['garage:valet-state', {}], + ['housing:overview', {}], + ['housing:key-candidates', { action: 'give' }], + ['mail:counts', {}], + ['mail:list', { folder: 'inbox' }], + ['map:getPlayerCoords', {}], + ['map:markers', {}], + ['marketplace:counts', {}], + ['marketplace:list', {}], + ['marketplace:list-own', {}], + ['marketplace:list-inquiries', {}], + ['marketplace:profile', {}], + ['messages:conversations', {}], + ['messages:gifs', { query: 'party' }], + ['music:bootstrap', {}], + ['notes:list', {}], + ['pages:list', {}], + ['pages:list-own', {}], + ['pages:profile', {}], + ['picstagram:bootstrap', {}], + ['picstagram:feed', { limit: 20 }], + ['picstagram:explore', { limit: 20 }], + ['picstagram:saved', {}], + ['picstagram:stories', {}], + ['picstagram:activities', {}], + ['radio:get', {}], + ['skyride:bootstrap', {}], + ['skyride:history', {}], + ['skyride:get-player-coords', {}], + ['weather:get', {}], +] + +async function post(baseUrl, endpoint, body = {}) { + const response = await fetch(`${baseUrl}/api/${endpoint}`, { + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + assert.equal(response.status, 200, endpoint) + return response.json() +} + +async function expectSuccess(baseUrl, endpoint, body = {}, data = false) { + const result = await post(baseUrl, endpoint, body) + assert.equal(result.success, true, `${endpoint}: ${result.error ?? 'failed'}`) + if (data) assert.notEqual(result.data, undefined, `${endpoint}: missing data`) + return result.data +} + +async function verifyStatefulActions(baseUrl) { + const noteId = `browser-note-${Date.now()}` + let notes = await expectSuccess( + baseUrl, + 'notes:create', + { + body: 'Created by the browser mock smoke test.', + id: noteId, + title: 'Browser test', + }, + true, + ) + assert( + notes.some((note) => note.id === noteId), + 'notes:create did not persist', + ) + notes = await expectSuccess( + baseUrl, + 'notes:update', + { + body: 'Updated browser test note.', + id: noteId, + title: 'Browser test updated', + }, + true, + ) + assert.equal( + notes.find((note) => note.id === noteId)?.title, + 'Browser test updated', + ) + notes = await expectSuccess(baseUrl, 'notes:delete', { id: noteId }, true) + assert( + !notes.some((note) => note.id === noteId), + 'notes:delete did not persist', + ) + + const contact = await expectSuccess( + baseUrl, + 'contacts:save', + { name: 'Browser Tester', phoneNumber: '5552223333' }, + true, + ) + await expectSuccess( + baseUrl, + 'contacts:favorite', + { favorite: true, id: contact.id }, + true, + ) + let contacts = await expectSuccess(baseUrl, 'contacts:list', {}, true) + assert.equal(contacts.find((item) => item.id === contact.id)?.favorite, true) + await expectSuccess(baseUrl, 'contacts:delete', { id: contact.id }) + contacts = await expectSuccess(baseUrl, 'contacts:list', {}, true) + assert( + !contacts.some((item) => item.id === contact.id), + 'contacts:delete did not persist', + ) + + const event = await expectSuccess( + baseUrl, + 'calendar:create', + { + allDay: false, + description: 'Stateful browser mock check', + endsAt: 2_000_003_600, + location: 'Legion Square', + reminderMinutes: 15, + startsAt: 2_000_000_000, + title: 'Browser test event', + }, + true, + ) + let events = await expectSuccess( + baseUrl, + 'calendar:list', + { endsAt: 4_102_444_800, startsAt: 0 }, + true, + ) + const storedEvent = events.find((item) => item.id === event.id) + assert(storedEvent, 'calendar:create did not persist') + await expectSuccess(baseUrl, 'calendar:update', { + ...storedEvent, + endsAt: storedEvent.endsAt / 1000, + startsAt: storedEvent.startsAt / 1000, + title: 'Updated browser test event', + }) + events = await expectSuccess( + baseUrl, + 'calendar:list', + { endsAt: 4_102_444_800, startsAt: 0 }, + true, + ) + assert.equal( + events.find((item) => item.id === event.id)?.title, + 'Updated browser test event', + ) + await expectSuccess(baseUrl, 'calendar:delete', { id: event.id }) + + const marker = await expectSuccess( + baseUrl, + 'map:create-marker', + { + color: '#2dd4bf', + coords: { x: 215.2, y: -810.1, z: 30.7 }, + icon: 'pin', + label: 'Browser test marker', + }, + true, + ) + let markers = await expectSuccess(baseUrl, 'map:markers', {}, true) + assert( + markers.some((item) => item.id === marker.id), + 'map:create-marker did not persist', + ) + await expectSuccess(baseUrl, 'map:delete-marker', { id: marker.id }) + markers = await expectSuccess(baseUrl, 'map:markers', {}, true) + assert( + !markers.some((item) => item.id === marker.id), + 'map:delete-marker did not persist', + ) + + const bankingBefore = await expectSuccess( + baseUrl, + 'banking:overview', + {}, + true, + ) + const bankingAfter = await expectSuccess( + baseUrl, + 'banking:transfer', + { amount: 125, phoneNumber: '5551110001' }, + true, + ) + assert.equal(bankingAfter.bank, bankingBefore.bank - 125) + + let radio = await expectSuccess( + baseUrl, + 'radio:connect', + { frequency: 42.5, secondaryFrequency: 7.25 }, + true, + ) + assert.equal(radio.frequency, 42.5) + radio = await expectSuccess(baseUrl, 'radio:set-volume', { volume: 44 }, true) + assert.equal(radio.volume, 44) + await expectSuccess(baseUrl, 'radio:disconnect') + + const playlistState = await expectSuccess( + baseUrl, + 'music:create-playlist', + { name: 'Browser Test Mix' }, + true, + ) + const playlist = playlistState.playlists.find( + (item) => item.name === 'Browser Test Mix', + ) + assert(playlist, 'music:create-playlist did not persist') + await expectSuccess( + baseUrl, + 'music:rename-playlist', + { id: playlist.id, name: 'Updated Browser Mix' }, + true, + ) + await expectSuccess( + baseUrl, + 'music:delete-playlist', + { id: playlist.id }, + true, + ) + + await expectSuccess(baseUrl, 'sim:eject') + let bootstrap = await expectSuccess( + baseUrl, + 'development:bootstrap', + {}, + true, + ) + assert.equal(bootstrap.device.sim, null) + const simConfirmation = await post(baseUrl, 'sim:insert', { + imei: '356938035643810', + }) + assert.deepEqual(simConfirmation, { + error: 'confirmation_required', + success: false, + }) + await expectSuccess(baseUrl, 'sim:insert', { + confirmed: true, + imei: '356938035643810', + }) + bootstrap = await expectSuccess(baseUrl, 'development:bootstrap', {}, true) + assert.equal(bootstrap.device.sim.number, '5559876543') + + const payphoneCall = await expectSuccess( + baseUrl, + 'payphone:dial', + { phoneNumber: '5551110001' }, + true, + ) + assert.equal(payphoneCall.state, 'connected') + await expectSuccess(baseUrl, 'payphone:hangup') + + const draft = await expectSuccess( + baseUrl, + 'mail:save-draft', + { + body: 'Browser test body', + recipients: ['alex@ifruit.com'], + subject: 'Browser test mail', + }, + true, + ) + const storedDraft = await expectSuccess( + baseUrl, + 'mail:get-draft', + { id: draft.id }, + true, + ) + assert.equal(storedDraft.subject, 'Browser test mail') + await expectSuccess(baseUrl, 'mail:delete-draft', { id: draft.id }) +} + +async function main() { + const server = app.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + const baseUrl = `http://127.0.0.1:${address.port}` + + try { + for (const [endpoint, body] of browserDataRequests) { + await expectSuccess(baseUrl, endpoint, body, true) + } + + await verifyStatefulActions(baseUrl) + + const lifecycleEndpoints = [ + 'camera:setActive', + 'camera:setFacing', + 'camera:setFlash', + 'camera:setFocus', + 'camera:setOrientation', + 'camera:setZoom', + 'close', + 'custom-app:lifecycle', + 'device:notification-open', + 'notification:focus', + 'sim:picker-close', + 'ui:opened', + 'ui:ready', + ] + for (const endpoint of lifecycleEndpoints) { + await expectSuccess(baseUrl, endpoint) + } + + const unknown = await post(baseUrl, 'development:missing-mock', {}) + assert.deepEqual(unknown, { + error: 'mock_endpoint_missing', + success: false, + }) + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + + console.log( + `Verified ${browserDataRequests.length} browser data endpoints and stateful app actions.`, + ) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 0592b08..355fe30 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -20,6 +20,13 @@ Config.Phone = { DeviceName = "iFruit Phone", } +Config.TestData = { + Enabled = true, + Command = "phonetestdata", + AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone + AdminGroups = { "admin", "superadmin" }, +} + Config.CustomApps = { Enabled = true, BundledApps = true, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 3334da8..347a95d 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -1,5 +1,10 @@ Locales["en"] = { CommandDescription = "Open your phone.", + TestData = { + CommandDescription = "Create or refresh test content in every data-driven phone app.", + Success = "Test content is ready. Your iFruit login is {email}. Reopen the phone to refresh every app.", + Failed = "Test content could not be created. Check the server console for details.", + }, FlipTokCommand = { usage = "Usage: /{command} <@handle> [on|off]", noPermission = "You do not have permission to manage FlipTok verification.", diff --git a/sky_phone/config/media.lua b/sky_phone/config/media.lua index 98ced28..0abfc1a 100644 --- a/sky_phone/config/media.lua +++ b/sky_phone/config/media.lua @@ -1,11 +1,11 @@ Config.Media = { - GiphyApiKey = "", + GiphyApiKeyConvar = "sky_phone_giphy_api_key", GifPageSize = 24, GifRating = "pg-13", UrlMaxLength = 2048, AllowedGifHosts = { "giphy.com" }, FiveManage = { - ApiKey = "e4UZ9y39JfkxHoZMAgRUVK6KMQsNCKPJ", -- Dashboard -> Tokens -> create a token with Media access. + ApiKeyConvar = "sky_phone_fivemanage_api_key", BaseUrl = "https://api.fivemanage.com/api/v3/file", RequestTimeoutMs = 10000, UploadTimeoutMs = 25000, diff --git a/sky_phone/config/payphones.lua b/sky_phone/config/payphones.lua new file mode 100644 index 0000000..e2a3ecd --- /dev/null +++ b/sky_phone/config/payphones.lua @@ -0,0 +1,248 @@ +-- Server-owned vanilla payphone positions used for authoritative proximity checks. +-- Generated from DurtyFree/gta-v-data-dumps worldPublicPhones.json at commit b65684e00f689fdec405c5f1055322c802d3c895. +-- Add custom-map booths here; their model must also be listed in Config.Payphones.Props. +Config.Payphones.Locations = { + { model = "prop_phonebox_01a", coords = { x = -1819.2284, y = 796.32294, z = 137.12784 } }, + { model = "prop_phonebox_01a", coords = { x = -1773.0256, y = -503.15234, z = 37.80706 } }, + { model = "prop_phonebox_01a", coords = { x = -1772.2114, y = -504.00488, z = 37.81461 } }, + { model = "prop_phonebox_01a", coords = { x = -1457.3734, y = -148.68604, z = 48.7486 } }, + { model = "prop_phonebox_01a", coords = { x = -1456.838, y = -149.31949, z = 48.68604 } }, + { model = "prop_phonebox_01a", coords = { x = -1456.3501, y = -149.95428, z = 48.61642 } }, + { model = "prop_phonebox_01a", coords = { x = -1438.6545, y = -210.77448, z = 47.10766 } }, + { model = "prop_phonebox_01a", coords = { x = -1418.2983, y = -291.36002, z = 42.96778 } }, + { model = "prop_phonebox_01a", coords = { x = -1417.4769, y = -290.64453, z = 42.93899 } }, + { model = "prop_phonebox_01a", coords = { x = -1416.5211, y = -289.8119, z = 42.90134 } }, + { model = "prop_phonebox_01a", coords = { x = -1318.0975, y = -380.79547, z = 35.73553 } }, + { model = "prop_phonebox_01a", coords = { x = -1316.9534, y = -378.42676, z = 35.74885 } }, + { model = "prop_phonebox_01a", coords = { x = -1316.2688, y = -378.07245, z = 35.73169 } }, + { model = "prop_phonebox_01a", coords = { x = -1315.5924, y = -377.7347, z = 35.72274 } }, + { model = "prop_phonebox_01a", coords = { x = -1261.6484, y = -519.13086, z = 30.83657 } }, + { model = "prop_phonebox_01a", coords = { x = -1260.9482, y = -519.9344, z = 30.75686 } }, + { model = "prop_phonebox_01a", coords = { x = -1260.0995, y = -520.9083, z = 30.66707 } }, + { model = "prop_phonebox_01a", coords = { x = -1121.5917, y = -825.6313, z = 14.94339 } }, + { model = "prop_phonebox_01a", coords = { x = -1120.9409, y = -825.0698, z = 14.98657 } }, + { model = "prop_phonebox_01a", coords = { x = -1120.2446, y = -824.4812, z = 15.06407 } }, + { model = "prop_phonebox_01a", coords = { x = -1079.6154, y = -451.0622, z = 35.6144 } }, + { model = "prop_phonebox_01a", coords = { x = -1079.23, y = -451.8739, z = 35.62138 } }, + { model = "prop_phonebox_01a", coords = { x = -1078.874, y = -452.55182, z = 35.62138 } }, + { model = "prop_phonebox_01a", coords = { x = -956.56256, y = -403.17123, z = 36.81676 } }, + { model = "prop_phonebox_01a", coords = { x = -956.1004, y = -404.09232, z = 36.81755 } }, + { model = "prop_phonebox_01a", coords = { x = -524.4403, y = -300.71704, z = 34.26753 } }, + { model = "prop_phonebox_01a", coords = { x = -523.64453, y = -300.41074, z = 34.26273 } }, + { model = "prop_phonebox_01a", coords = { x = -522.872, y = -300.1134, z = 34.25807 } }, + { model = "prop_phonebox_01a", coords = { x = -449.44238, y = -272.8244, z = 34.93996 } }, + { model = "prop_phonebox_01a", coords = { x = -448.8816, y = -274.12805, z = 34.96191 } }, + { model = "prop_phonebox_01a", coords = { x = -448.36926, y = -275.31906, z = 34.96507 } }, + { model = "prop_phonebox_01a", coords = { x = -329.23917, y = 6224.885, z = 30.47861 } }, + { model = "prop_phonebox_01a", coords = { x = -310.30554, y = 6205.3, z = 30.4465 } }, + { model = "prop_phonebox_01a", coords = { x = -280.64215, y = 6224.2314, z = 30.45544 } }, + { model = "prop_phonebox_01a", coords = { x = -243.25082, y = 279.90717, z = 91.04989 } }, + { model = "prop_phonebox_01a", coords = { x = -234.61494, y = 6176.931, z = 30.43884 } }, + { model = "prop_phonebox_01a", coords = { x = -233.6865, y = 6176.0547, z = 30.43884 } }, + { model = "prop_phonebox_01a", coords = { x = -184.35658, y = 6331.4697, z = 30.48767 } }, + { model = "prop_phonebox_01a", coords = { x = -183.68753, y = 6332.1597, z = 30.48987 } }, + { model = "prop_phonebox_01a", coords = { x = -154.76048, y = 6352.27, z = 30.56079 } }, + { model = "prop_phonebox_01a", coords = { x = -153.88358, y = 6351.417, z = 30.56079 } }, + { model = "prop_phonebox_01a", coords = { x = -119.91727, y = 6287.283, z = 30.45911 } }, + { model = "prop_phonebox_01a", coords = { x = -119.44898, y = 6286.768, z = 30.45911 } }, + { model = "prop_phonebox_01a", coords = { x = -92.08037, y = 6462.644, z = 30.44397 } }, + { model = "prop_phonebox_01a", coords = { x = -90.61212, y = 6464.0566, z = 30.44397 } }, + { model = "prop_phonebox_01a", coords = { x = -46.3855, y = 6511.0156, z = 30.44861 } }, + { model = "prop_phonebox_01a", coords = { x = -25.6331, y = 6495.123, z = 30.48767 } }, + { model = "prop_phonebox_01a", coords = { x = -24.59157, y = 6496.0537, z = 30.48767 } }, + { model = "prop_phonebox_01a", coords = { x = 110.07694, y = -1694.206, z = 28.29156 } }, + { model = "prop_phonebox_01a", coords = { x = 136.9704, y = 196.05042, z = 105.73364 } }, + { model = "prop_phonebox_01a", coords = { x = 137.75732, y = 195.764, z = 105.70988 } }, + { model = "prop_phonebox_01a", coords = { x = 138.48807, y = 195.49808, z = 105.69342 } }, + { model = "prop_phonebox_01a", coords = { x = 173.45892, y = -1547.1165, z = 28.25158 } }, + { model = "prop_phonebox_01a", coords = { x = 215.71725, y = -1783.2102, z = 27.99637 } }, + { model = "prop_phonebox_01a", coords = { x = 215.94612, y = -1518.9603, z = 28.29362 } }, + { model = "prop_phonebox_01a", coords = { x = 228.6216, y = -1545.9579, z = 28.28108 } }, + { model = "prop_phonebox_01a", coords = { x = 229.1375, y = -1545.6771, z = 28.28108 } }, + { model = "prop_phonebox_01a", coords = { x = 295.67847, y = -1360.8624, z = 30.91401 } }, + { model = "prop_phonebox_01a", coords = { x = 296.17984, y = -1360.2825, z = 30.91899 } }, + { model = "prop_phonebox_01a", coords = { x = 532.401, y = -151.79816, z = 56.07613 } }, + { model = "prop_phonebox_01a", coords = { x = 539.5577, y = -166.04846, z = 53.4862 } }, + { model = "prop_phonebox_01a", coords = { x = 812.21716, y = -289.03873, z = 65.46264 } }, + { model = "prop_phonebox_01a", coords = { x = 812.3678, y = -289.84793, z = 65.46264 } }, + { model = "prop_phonebox_01a", coords = { x = 819.023, y = -94.03439, z = 79.57648 } }, + { model = "prop_phonebox_01a", coords = { x = 819.37396, y = -93.47577, z = 79.57648 } }, + { model = "prop_phonebox_01a", coords = { x = 891.8809, y = -140.80609, z = 76.11372 } }, + { model = "prop_phonebox_01a", coords = { x = 963.6167, y = -142.79822, z = 73.46588 } }, + { model = "prop_phonebox_01a", coords = { x = 1079.2015, y = -776.68054, z = 57.25418 } }, + { model = "prop_phonebox_01a", coords = { x = 1156.3375, y = -776.99866, z = 56.58559 } }, + { model = "prop_phonebox_01a", coords = { x = 1159.7463, y = -374.87518, z = 66.51784 } }, + { model = "prop_phonebox_01a", coords = { x = 1166.4825, y = -321.59958, z = 68.25383 } }, + { model = "prop_phonebox_01a", coords = { x = 1169.4127, y = 2702.8025, z = 36.99265 } }, + { model = "prop_phonebox_01a", coords = { x = 1170.3059, y = -455.76053, z = 65.49249 } }, + { model = "prop_phonebox_01a", coords = { x = 1172.7772, y = -297.61606, z = 68.01613 } }, + { model = "prop_phonebox_01a", coords = { x = 1172.8646, y = -298.23972, z = 68.01981 } }, + { model = "prop_phonebox_01a", coords = { x = 1173.8961, y = -421.43643, z = 66.07632 } }, + { model = "prop_phonebox_01a", coords = { x = 1201.426, y = -488.8848, z = 64.67129 } }, + { model = "prop_phonebox_01a", coords = { x = 1222.6125, y = -397.32706, z = 67.32355 } }, + { model = "prop_phonebox_01a", coords = { x = 1801.4076, y = 4597.0137, z = 36.67796 } }, + { model = "prop_phonebox_01a", coords = { x = 2558.9167, y = 367.14368, z = 107.63403 } }, + { model = "prop_phonebox_01b", coords = { x = -1684.4233, y = -266.45306, z = 50.89204 } }, + { model = "prop_phonebox_01b", coords = { x = -1683.9019, y = -265.70874, z = 50.89204 } }, + { model = "prop_phonebox_01b", coords = { x = -1543.9277, y = -433.13232, z = 34.57933 } }, + { model = "prop_phonebox_01b", coords = { x = -1543.0599, y = -432.05966, z = 34.58469 } }, + { model = "prop_phonebox_01b", coords = { x = -1522.4791, y = -407.05118, z = 34.58695 } }, + { model = "prop_phonebox_01b", coords = { x = -1412.1201, y = -383.80542, z = 35.68469 } }, + { model = "prop_phonebox_01b", coords = { x = -1205.1965, y = -1393.6274, z = 3.07721 } }, + { model = "prop_phonebox_01b", coords = { x = -1150.6671, y = -1392.6455, z = 4.11812 } }, + { model = "prop_phonebox_01b", coords = { x = -1142.0598, y = -725.3442, z = 19.77577 } }, + { model = "prop_phonebox_01b", coords = { x = -1080.87, y = -2574.942, z = 12.91528 } }, + { model = "prop_phonebox_01b", coords = { x = -1061.7015, y = -2541.7412, z = 12.91528 } }, + { model = "prop_phonebox_01b", coords = { x = -1061.5474, y = -2541.4744, z = 19.15571 } }, + { model = "prop_phonebox_01b", coords = { x = -1046.9408, y = -2516.175, z = 12.91528 } }, + { model = "prop_phonebox_01b", coords = { x = -1046.8787, y = -2516.0674, z = 19.15571 } }, + { model = "prop_phonebox_01b", coords = { x = -1034.5115, y = -2494.6467, z = 19.15571 } }, + { model = "prop_phonebox_01b", coords = { x = -1024.4517, y = -2477.2227, z = 12.91528 } }, + { model = "prop_phonebox_01b", coords = { x = -765.40045, y = -848.8706, z = 21.11398 } }, + { model = "prop_phonebox_01b", coords = { x = -764.83673, y = -848.8654, z = 21.13071 } }, + { model = "prop_phonebox_01b", coords = { x = -756.90735, y = 5586.8223, z = 35.71351 } }, + { model = "prop_phonebox_01b", coords = { x = -756.90735, y = 5587.636, z = 35.71351 } }, + { model = "prop_phonebox_01b", coords = { x = -745.72156, y = 5558.714, z = 35.71351 } }, + { model = "prop_phonebox_01b", coords = { x = -743.95874, y = 5558.714, z = 35.71351 } }, + { model = "prop_phonebox_01b", coords = { x = -715.921, y = 123.33502, z = 54.99648 } }, + { model = "prop_phonebox_01b", coords = { x = -715.40906, y = 123.65546, z = 55.01274 } }, + { model = "prop_phonebox_01b", coords = { x = -700.7271, y = -916.8699, z = 18.21408 } }, + { model = "prop_phonebox_01b", coords = { x = -700.1226, y = -916.8699, z = 18.21408 } }, + { model = "prop_phonebox_01b", coords = { x = -685.93774, y = -854.8967, z = 22.88396 } }, + { model = "prop_phonebox_01b", coords = { x = -670.3093, y = -819.59973, z = 23.4098 } }, + { model = "prop_phonebox_01b", coords = { x = -669.60815, y = -819.6056, z = 23.42427 } }, + { model = "prop_phonebox_01b", coords = { x = -668.81213, y = -819.6378, z = 23.43811 } }, + { model = "prop_phonebox_01b", coords = { x = -665.19354, y = -670.83777, z = 30.40002 } }, + { model = "prop_phonebox_01b", coords = { x = -664.59607, y = -670.8341, z = 30.40393 } }, + { model = "prop_phonebox_01b", coords = { x = -663.99866, y = -670.83044, z = 30.41797 } }, + { model = "prop_phonebox_01b", coords = { x = -655.2001, y = -859.74493, z = 23.50043 } }, + { model = "prop_phonebox_01b", coords = { x = -654.1605, y = -707.27155, z = 28.40153 } }, + { model = "prop_phonebox_01b", coords = { x = -654.1605, y = -706.51654, z = 28.47383 } }, + { model = "prop_phonebox_01b", coords = { x = -654.1605, y = -705.7615, z = 28.54753 } }, + { model = "prop_phonebox_01b", coords = { x = -611.56744, y = -2237.6104, z = 5.10603 } }, + { model = "prop_phonebox_01b", coords = { x = -530.0182, y = -1286.1543, z = 25.03622 } }, + { model = "prop_phonebox_01b", coords = { x = -529.77765, y = -1285.6444, z = 25.04207 } }, + { model = "prop_phonebox_01b", coords = { x = -529.6009, y = -1286.3458, z = 25.04428 } }, + { model = "prop_phonebox_01b", coords = { x = -529.36365, y = -1285.8345, z = 25.03622 } }, + { model = "prop_phonebox_01b", coords = { x = -468.24023, y = -396.44247, z = 32.8951 } }, + { model = "prop_phonebox_01b", coords = { x = -467.58286, y = -396.50574, z = 32.8951 } }, + { model = "prop_phonebox_01b", coords = { x = -259.3869, y = -604.76556, z = 32.59827 } }, + { model = "prop_phonebox_01b", coords = { x = -259.14352, y = -603.98016, z = 32.65002 } }, + { model = "prop_phonebox_01b", coords = { x = -241.57574, y = -766.2026, z = 31.73654 } }, + { model = "prop_phonebox_01b", coords = { x = -241.29694, y = -765.4682, z = 31.77955 } }, + { model = "prop_phonebox_01b", coords = { x = -239.74597, y = -978.22943, z = 28.26393 } }, + { model = "prop_phonebox_01b", coords = { x = -239.51797, y = -977.59296, z = 28.26393 } }, + { model = "prop_phonebox_01b", coords = { x = -178.84961, y = -52.06338, z = 51.10093 } }, + { model = "prop_phonebox_01b", coords = { x = -177.28662, y = -713.48505, z = 33.39728 } }, + { model = "prop_phonebox_01b", coords = { x = -147.61765, y = -287.15277, z = 39.43044 } }, + { model = "prop_phonebox_01b", coords = { x = -147.37784, y = -286.44186, z = 39.49831 } }, + { model = "prop_phonebox_01b", coords = { x = -73.17541, y = -641.5052, z = 35.24065 } }, + { model = "prop_phonebox_01b", coords = { x = -72.92773, y = -640.8415, z = 35.24065 } }, + { model = "prop_phonebox_01b", coords = { x = -53.45404, y = -94.169, z = 56.7686 } }, + { model = "prop_phonebox_01b", coords = { x = -27.98413, y = -100.90671, z = 56.35694 } }, + { model = "prop_phonebox_01b", coords = { x = -26.48154, y = -110.65947, z = 56.06785 } }, + { model = "prop_phonebox_01b", coords = { x = -8.06136, y = -731.61005, z = 43.22259 } }, + { model = "prop_phonebox_01b", coords = { x = -7.37235, y = -731.8549, z = 43.22768 } }, + { model = "prop_phonebox_01b", coords = { x = 43.99314, y = -680.87616, z = 43.20672 } }, + { model = "prop_phonebox_01b", coords = { x = 44.30204, y = -680.0512, z = 43.20672 } }, + { model = "prop_phonebox_01b", coords = { x = 120.37446, y = -205.12677, z = 53.61985 } }, + { model = "prop_phonebox_01b", coords = { x = 121.18489, y = -205.42413, z = 53.61985 } }, + { model = "prop_phonebox_01b", coords = { x = 129.40686, y = 245.3497, z = 106.42847 } }, + { model = "prop_phonebox_01b", coords = { x = 140.18076, y = -1033.1602, z = 28.34242 } }, + { model = "prop_phonebox_01b", coords = { x = 174.5452, y = -1116.4456, z = 28.28443 } }, + { model = "prop_phonebox_01b", coords = { x = 175.22937, y = -1116.4185, z = 28.28425 } }, + { model = "prop_phonebox_01b", coords = { x = 213.8157, y = -852.6208, z = 29.38956 } }, + { model = "prop_phonebox_01b", coords = { x = 214.44952, y = -852.86725, z = 29.38709 } }, + { model = "prop_phonebox_01b", coords = { x = 233.49872, y = 334.44766, z = 104.52145 } }, + { model = "prop_phonebox_01b", coords = { x = 296.66183, y = -1359.7725, z = 30.92093 } }, + { model = "prop_phonebox_01b", coords = { x = 372.30563, y = -966.37286, z = 28.41298 } }, + { model = "prop_phonebox_01b", coords = { x = 394.25433, y = -799.7535, z = 28.23798 } }, + { model = "prop_phonebox_01b", coords = { x = 394.25433, y = -798.6486, z = 28.23798 } }, + { model = "prop_phonebox_01b", coords = { x = 397.567, y = -921.3287, z = 28.3982 } }, + { model = "prop_phonebox_01b", coords = { x = 397.567, y = -920.658, z = 28.3982 } }, + { model = "prop_phonebox_01b", coords = { x = 415.17245, y = -910.94476, z = 28.3982 } }, + { model = "prop_phonebox_01b", coords = { x = 436.0411, y = 137.20285, z = 99.43968 } }, + { model = "prop_phonebox_01b", coords = { x = 436.83813, y = 136.89954, z = 99.38892 } }, + { model = "prop_phonebox_01b", coords = { x = 439.8047, y = -606.65063, z = 27.69825 } }, + { model = "prop_phonebox_01b", coords = { x = 439.99564, y = -604.67474, z = 27.69747 } }, + { model = "prop_phonebox_01b", coords = { x = 445.3808, y = 3567.5305, z = 32.21765 } }, + { model = "prop_phonebox_01b", coords = { x = 452.6532, y = -612.11816, z = 27.54012 } }, + { model = "prop_phonebox_01b", coords = { x = 452.7997, y = -610.4434, z = 27.5457 } }, + { model = "prop_phonebox_01b", coords = { x = 535.5781, y = 102.93228, z = 95.56698 } }, + { model = "prop_phonebox_01b", coords = { x = 779.83923, y = -1755.3914, z = 28.47611 } }, + { model = "prop_phonebox_01b", coords = { x = 780.2285, y = -1755.4475, z = 28.46564 } }, + { model = "prop_phonebox_01b", coords = { x = 809.3085, y = -1074.9281, z = 27.67919 } }, + { model = "prop_phonebox_01b", coords = { x = 903.52423, y = 3646.1294, z = 31.70571 } }, + { model = "prop_phonebox_01b", coords = { x = 1051.0497, y = 2661.3877, z = 38.52392 } }, + { model = "prop_phonebox_01b", coords = { x = 1181.1997, y = 2703.214, z = 37.1464 } }, + { model = "prop_phonebox_01b", coords = { x = 1206.4275, y = 2647.8894, z = 36.81204 } }, + { model = "prop_phonebox_01b", coords = { x = 1401.1744, y = 3602.0786, z = 34.01619 } }, + { model = "prop_phonebox_01b", coords = { x = 1662.8026, y = 4841.53, z = 41.0313 } }, + { model = "prop_phonebox_01b", coords = { x = 1662.9365, y = 4840.332, z = 41.0313 } }, + { model = "prop_phonebox_01b", coords = { x = 1692.9031, y = 6432.025, z = 31.73361 } }, + { model = "prop_phonebox_01b", coords = { x = 1696.5485, y = 3776.0618, z = 33.71252 } }, + { model = "prop_phonebox_01b", coords = { x = 1696.7177, y = 4790.302, z = 40.89749 } }, + { model = "prop_phonebox_01b", coords = { x = 1860.4072, y = 3696.2563, z = 33.26152 } }, + { model = "prop_phonebox_01b", coords = { x = 1861.0801, y = 3695.0903, z = 33.26152 } }, + { model = "prop_phonebox_01b", coords = { x = 2005.9707, y = 3782.6196, z = 31.15662 } }, + { model = "prop_phonebox_01b", coords = { x = 2006.7942, y = 3783.1003, z = 31.14984 } }, + { model = "prop_phonebox_04", coords = { x = -2969.4265, y = 397.46487, z = 14.10208 } }, + { model = "prop_phonebox_04", coords = { x = -1417.7056, y = -94.50974, z = 51.41046 } }, + { model = "prop_phonebox_04", coords = { x = -1416.8014, y = -94.11159, z = 51.44441 } }, + { model = "prop_phonebox_04", coords = { x = -1415.9122, y = -93.72023, z = 51.49042 } }, + { model = "prop_phonebox_04", coords = { x = -1294.0234, y = -390.34976, z = 35.44277 } }, + { model = "prop_phonebox_04", coords = { x = -1293.4121, y = -391.38864, z = 35.44632 } }, + { model = "prop_phonebox_04", coords = { x = -1241.7491, y = -464.37216, z = 32.537 } }, + { model = "prop_phonebox_04", coords = { x = -1224.2532, y = -322.51794, z = 36.57259 } }, + { model = "prop_phonebox_04", coords = { x = -1223.0624, y = -321.95178, z = 36.59326 } }, + { model = "prop_phonebox_04", coords = { x = -1074.0209, y = -397.75607, z = 35.95449 } }, + { model = "prop_phonebox_04", coords = { x = -1025.3336, y = -216.04681, z = 36.93829 } }, + { model = "prop_phonebox_04", coords = { x = -1023.97375, y = -216.74884, z = 36.9369 } }, + { model = "prop_phonebox_04", coords = { x = -985.19824, y = -414.10977, z = 36.85289 } }, + { model = "prop_phonebox_04", coords = { x = -979.73254, y = -369.2069, z = 36.856 } }, + { model = "prop_phonebox_04", coords = { x = -979.1488, y = -370.3092, z = 36.856 } }, + { model = "prop_phonebox_04", coords = { x = -965.37616, y = -2524.3992, z = 13.00643 } }, + { model = "prop_phonebox_04", coords = { x = -963.65985, y = -247.05435, z = 37.0568 } }, + { model = "prop_phonebox_04", coords = { x = -896.8487, y = -247.80585, z = 39.07844 } }, + { model = "prop_phonebox_04", coords = { x = -865.3409, y = -2528.5645, z = 13.00643 } }, + { model = "prop_phonebox_04", coords = { x = -821.83124, y = -251.49579, z = 36.0627 } }, + { model = "prop_phonebox_04", coords = { x = -821.29346, y = -252.4495, z = 36.05127 } }, + { model = "prop_phonebox_04", coords = { x = -701.46173, y = -371.56976, z = 33.2833 } }, + { model = "prop_phonebox_04", coords = { x = -700.3627, y = -372.04968, z = 33.27078 } }, + { model = "prop_phonebox_04", coords = { x = -619.5328, y = -207.68121, z = 36.3736 } }, + { model = "prop_phonebox_04", coords = { x = -618.975, y = -208.61508, z = 36.35005 } }, + { model = "prop_phonebox_04", coords = { x = -617.05457, y = -422.20978, z = 33.7873 } }, + { model = "prop_phonebox_04", coords = { x = -557.25586, y = -386.67517, z = 34.11347 } }, + { model = "prop_phonebox_04", coords = { x = -556.05286, y = -386.66565, z = 34.11989 } }, + { model = "prop_phonebox_04", coords = { x = -554.8701, y = -386.6563, z = 34.12583 } }, + { model = "prop_phonebox_04", coords = { x = -546.57184, y = -334.10083, z = 34.16116 } }, + { model = "prop_phonebox_04", coords = { x = -544.17737, y = -157.39006, z = 37.53791 } }, + { model = "prop_phonebox_04", coords = { x = -388.49542, y = -321.52948, z = 32.10458 } }, + { model = "prop_phonebox_04", coords = { x = -387.68488, y = -322.21454, z = 32.05279 } }, + { model = "prop_phonebox_04", coords = { x = -360.33978, y = -267.18268, z = 32.73604 } }, + { model = "prop_phonebox_04", coords = { x = -347.059, y = -1490.9738, z = 29.79159 } }, + { model = "prop_phonebox_04", coords = { x = -345.83786, y = -1490.9738, z = 29.7867 } }, + { model = "prop_phonebox_04", coords = { x = -263.0376, y = -766.90546, z = 31.57576 } }, + { model = "prop_phonebox_04", coords = { x = -262.6508, y = -766.04095, z = 31.60592 } }, + { model = "prop_phonebox_04", coords = { x = -213.1738, y = -696.5944, z = 32.80729 } }, + { model = "prop_phonebox_04", coords = { x = -174.76907, y = -674.9272, z = 33.27862 } }, + { model = "prop_phonebox_04", coords = { x = -173.80676, y = -675.35236, z = 33.29762 } }, + { model = "prop_phonebox_04", coords = { x = -138.00961, y = -799.9025, z = 31.10711 } }, + { model = "prop_phonebox_04", coords = { x = -137.64026, y = -798.8024, z = 31.14563 } }, + { model = "prop_phonebox_04", coords = { x = 55.44337, y = -1081.1333, z = 28.45174 } }, + { model = "prop_phonebox_04", coords = { x = 55.90165, y = -1080.282, z = 28.45174 } }, + { model = "prop_phonebox_04", coords = { x = 188.01767, y = -1043.9451, z = 28.32789 } }, + { model = "prop_phonebox_04", coords = { x = 189.79306, y = -1044.5588, z = 28.32789 } }, + { model = "prop_phonebox_04", coords = { x = 298.28317, y = -795.153, z = 28.4778 } }, + { model = "prop_phonebox_04", coords = { x = 298.62607, y = -794.289, z = 28.4778 } }, + { model = "prop_phonebox_04", coords = { x = 347.45938, y = -730.9255, z = 28.28353 } }, + { model = "prop_phonebox_04", coords = { x = 564.7501, y = -1748.7141, z = 28.31245 } }, + { model = "prop_phonebox_04", coords = { x = 653.1738, y = 272.5705, z = 102.29323 } }, + { model = "prop_phonebox_04", coords = { x = 654.2313, y = 271.95996, z = 102.29323 } }, + { model = "prop_phonebox_04", coords = { x = 1214.8445, y = -1385.6348, z = 34.34755 } }, + { model = "prop_phonebox_04", coords = { x = 1214.8445, y = -1384.5386, z = 34.34755 } }, + { model = "prop_phonebox_04", coords = { x = 1662.002, y = 4819.523, z = 41.04535 } }, + { model = "prop_phonebox_04", coords = { x = 1816.4236, y = 3671.6497, z = 33.29268 } }, + { model = "prop_phonebox_04", coords = { x = 1818.1936, y = 3668.7485, z = 33.29268 } }, + { model = "prop_phonebox_04", coords = { x = 2007.0574, y = 3784.7974, z = 31.20895 } }, +} diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index fb4cf85..e5f67e7 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -30,6 +30,7 @@ client_scripts { 'source/bridge/client/housing.lua', 'source/bridge/client/housing/*.lua', 'source/client/animations.lua', + 'source/client/focus.lua', 'source/client/camera.lua', 'source/client/garage.lua', 'source/client/skyride.lua', @@ -46,6 +47,7 @@ client_scripts { server_scripts { '@oxmysql/lib/MySQL.lua', 'config/config.lua', + 'config/payphones.lua', 'config/companies.lua', 'config/media.lua', 'config/music.lua', @@ -66,6 +68,7 @@ server_scripts { 'source/server/companies.lua', 'source/server/custom_app_storage.lua', 'source/server/sim.lua', + 'source/server/payphones.lua', 'source/server/calls.lua', 'source/server/media.lua', 'source/server/messages.lua', @@ -89,6 +92,7 @@ server_scripts { 'source/server/calendar.lua', 'source/server/music.lua', 'source/server/radio.lua', + 'source/server/testdata.lua', } files { diff --git a/sky_phone/source/client/camera.lua b/sky_phone/source/client/camera.lua index 1cfa566..581d9b0 100644 --- a/sky_phone/source/client/camera.lua +++ b/sky_phone/source/client/camera.lua @@ -6,6 +6,10 @@ local ultrawide_fov_multiplier = 2.0 local front_camera_distance = 0.75 local front_camera_height = 0.05 local front_camera_target_height = 0.03 +local unfocused_camera_controls = { + 1, -- INPUT_LOOK_LR + 2, -- INPUT_LOOK_UD +} local camera_state = { active = false, enforcing = false, @@ -16,6 +20,7 @@ local camera_state = { front_camera_handle = nil, landscape = false, ultrawide_camera_handle = nil, + applied_nui_focus = true, nui_focused = true, previous_ped_view = nil, previous_radar_hidden = nil, @@ -145,27 +150,32 @@ local function restore_camera_view() end end -local function set_camera_focus(focused) - if camera_state.nui_focused == focused then - return +local function apply_unfocused_camera_controls() + DisableAllControlActions(0) + for _, control in ipairs(unfocused_camera_controls) do + EnableControlAction(0, control, true) end - camera_state.nui_focused = focused - if focused then - SetNuiFocus(true, true) - SetNuiFocusKeepInput(false) - SendNUIMessage({ type = "camera:focus", data = { focused = true } }) - return - end - SetNuiFocus(false, false) - SetNuiFocusKeepInput(true) - SendNUIMessage({ type = "camera:focus", data = { focused = false } }) + DisablePlayerFiring(PlayerId(), true) +end + +local function update_camera_focus_claim() + TriggerEvent("sky_phone:client:setCameraFocus", { + active = camera_state.active, + nuiFocused = camera_state.nui_focused, + }) +end + +local set_camera_focus + +local function watch_unfocused_camera_controls() if camera_state.focus_watcher then return end camera_state.focus_watcher = true CreateThread(function() - while camera_state.active and not camera_state.nui_focused do - if IsControlJustReleased(0, 22) then + while camera_state.active and not camera_state.applied_nui_focus do + apply_unfocused_camera_controls() + if IsDisabledControlJustReleased(0, 22) then set_camera_focus(true) break end @@ -175,6 +185,28 @@ local function set_camera_focus(focused) end) end +set_camera_focus = function(focused) + camera_state.nui_focused = focused + update_camera_focus_claim() +end + +AddEventHandler("sky_phone:client:cameraFocusApplied", function(data) + if type(data) ~= "table" + or type(data.active) ~= "boolean" + or type(data.focused) ~= "boolean" + or type(data.gameInput) ~= "boolean" + then + return + end + if camera_state.applied_nui_focus ~= data.focused then + camera_state.applied_nui_focus = data.focused + SendNUIMessage({ type = "camera:focus", data = { focused = data.focused } }) + end + if data.active and data.gameInput then + watch_unfocused_camera_controls() + end +end) + local function set_camera_active(active) if camera_state.active == active then return @@ -251,11 +283,8 @@ local function set_camera_active(active) clear_front_camera() clear_ultrawide_camera() restore_camera_view() - if not camera_state.nui_focused then - camera_state.nui_focused = true - SetNuiFocusKeepInput(false) - SetNuiFocus(true, true) - end + camera_state.nui_focused = true + update_camera_focus_claim() TriggerEvent("sky_phone:animation:camera", { active = false, front = false, @@ -325,58 +354,102 @@ local function set_camera_zoom(zoom) end RegisterNUICallback("camera:setActive", function(data, cb) - set_camera_active(data and data.active == true) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end + set_camera_active(data.active == true) cb({ success = true }) end) RegisterNUICallback("camera:setFocus", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end if camera_state.active then - set_camera_focus(data and data.focused == true) + set_camera_focus(data.focused == true) end cb({ success = true }) end) RegisterNUICallback("camera:setFlash", function(data, cb) - set_flash_enabled(data and data.enabled == true) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end + set_flash_enabled(data.enabled == true) cb({ success = true }) end) RegisterNUICallback("camera:setFacing", function(data, cb) - set_front_camera(data and data.front == true) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end + set_front_camera(data.front == true) cb({ success = true }) end) RegisterNUICallback("camera:setOrientation", function(data, cb) - set_camera_landscape(data and data.landscape == true) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end + set_camera_landscape(data.landscape == true) cb({ success = true }) end) RegisterNUICallback("camera:setZoom", function(data, cb) - cb({ success = set_camera_zoom(tonumber(data and data.zoom)) }) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end + cb({ success = set_camera_zoom(tonumber(data.zoom)) }) end) RegisterNUICallback("media:requestUpload", function(data, cb) - TriggerServerEvent("sky_phone:media:request-upload", data or {}) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end + TriggerServerEvent("sky_phone:media:request-upload", data) cb({ success = true }) end) RegisterNUICallback("media:completeUpload", function(data, cb) - TriggerServerEvent("sky_phone:media:complete-upload", data or {}) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end + TriggerServerEvent("sky_phone:media:complete-upload", data) cb({ success = true }) end) RegisterNUICallback("media:cancelUpload", function(data, cb) - TriggerServerEvent("sky_phone:media:cancel-upload", data or {}) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end + TriggerServerEvent("sky_phone:media:cancel-upload", data) cb({ success = true }) end) RegisterNUICallback("media:failUpload", function(data, cb) - TriggerServerEvent("sky_phone:media:fail-upload", data or {}) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end + TriggerServerEvent("sky_phone:media:fail-upload", data) cb({ success = true }) end) RegisterNUICallback("gallery:delete", function(data, cb) - TriggerServerEvent("sky_phone:media:delete", data or {}) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end + TriggerServerEvent("sky_phone:media:delete", data) cb({ success = true }) end) @@ -401,6 +474,5 @@ AddEventHandler("onResourceStop", function(resource_name) if resource_name == GetCurrentResourceName() then set_flash_enabled(false) set_camera_active(false) - SetNuiFocusKeepInput(false) end end) diff --git a/sky_phone/source/client/crewlink.lua b/sky_phone/source/client/crewlink.lua index 5ecf4eb..4767466 100644 --- a/sky_phone/source/client/crewlink.lua +++ b/sky_phone/source/client/crewlink.lua @@ -16,6 +16,10 @@ local function draw_overhead_label(coords, username, role) end RegisterNUICallback("crewlink:live", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end local result = Bridge.Callbacks.Trigger("sky_phone:crewlink:live", data) overhead_members = result and result.success and result.data and result.data.overheadMembers or {} overhead_expires_at = GetGameTimer() + Config.CrewLink.OverheadRefreshMilliseconds * 2 diff --git a/sky_phone/source/client/focus.lua b/sky_phone/source/client/focus.lua new file mode 100644 index 0000000..8252694 --- /dev/null +++ b/sky_phone/source/client/focus.lua @@ -0,0 +1,21 @@ +SkyPhoneFocus = {} + +function SkyPhoneFocus.Resolve(state) + if state.activity_suspended then + return { focused = false, keep_input = false } + end + if state.call_focus then + return { focused = true, keep_input = false } + end + if state.camera_active and not state.camera_nui_focused then + return { focused = false, keep_input = true } + end + return { + focused = state.is_open + or state.notification_focus + or state.payphone_focus + or state.sim_picker_open + or (state.camera_active and state.camera_nui_focused), + keep_input = false, + } +end diff --git a/sky_phone/source/client/garage.lua b/sky_phone/source/client/garage.lua index 6a40adf..cee1d5d 100644 --- a/sky_phone/source/client/garage.lua +++ b/sky_phone/source/client/garage.lua @@ -441,6 +441,10 @@ local function run_valet_delivery(order) end RegisterNUICallback("garage:valet-request", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end if current_valet then cb({ success = false, error = "valet_active" }) return diff --git a/sky_phone/source/client/housing.lua b/sky_phone/source/client/housing.lua index d54d217..93f3b5b 100644 --- a/sky_phone/source/client/housing.lua +++ b/sky_phone/source/client/housing.lua @@ -11,18 +11,18 @@ local function server_prepare(action, data) end local function suspend_phone() - SetNuiFocus(false, false) + TriggerEvent("sky_phone:client:setSuspended", true) TriggerEvent("sky_phone:animation:phone", false) SendNUIMessage({ type = "app:suspend" }) end local function resume_phone() SendNUIMessage({ type = "app:resume" }) - SetNuiFocus(true, true) + TriggerEvent("sky_phone:client:setSuspended", false) TriggerEvent("sky_phone:animation:phone", true) end -local function stop_camera() +local function stop_camera(resume) local state = camera_state if not state then return @@ -37,7 +37,9 @@ local function stop_camera() if DoesEntityExist(state.ped) then FreezeEntityPosition(state.ped, state.frozen) end - resume_phone() + if resume ~= false then + resume_phone() + end end local function camera_number(value, fallback) @@ -105,13 +107,17 @@ local function run_camera(provider_name, camera_data) local maximum_left = camera_number(camera_data.maximumLeft) local maximum_right = camera_number(camera_data.maximumRight) local night_vision = camera_state.night_vision + local resume_after_camera = false + local close_phone_after_camera = false while camera_state and camera_state.camera == camera do Wait(0) DisableAllControlActions(0) - if IsDisabledControlJustPressed(0, config.ExitControl) - or IsPedDeadOrDying(ped, true) - or GetResourceState(provider_name) ~= "started" - then + if IsDisabledControlJustPressed(0, config.ExitControl) then + resume_after_camera = true + break + end + if IsPedDeadOrDying(ped, true) or GetResourceState(provider_name) ~= "started" then + close_phone_after_camera = true break end @@ -147,12 +153,29 @@ local function run_camera(provider_name, camera_data) SetNightvision(night_vision) end end - stop_camera() + stop_camera(resume_after_camera) + if close_phone_after_camera then + TriggerEvent("sky_phone:client:forceClose") + end end -RegisterNUICallback("housing:overview", function(_, cb) +RegisterNetEvent("sky_phone:device:invalidated", function() + stop_camera(false) +end) + +RegisterNUICallback("housing:overview", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end local result = Bridge.Callbacks.Trigger("sky_phone:housing:overview", {}) - cb(result or { success = false, error = "request_failed" }) + cb(type(result) == "table" and result or { success = false, error = "request_failed" }) +end) + +AddEventHandler("sky_phone:client:nuiReady", function() + if camera_state then + suspend_phone() + end end) RegisterNUICallback("housing:key-candidates", function(data, cb) @@ -161,7 +184,7 @@ RegisterNUICallback("housing:key-candidates", function(data, cb) return end local result = server_prepare("key_candidates", data) - cb(result or { success = false, error = "request_failed" }) + cb(type(result) == "table" and result or { success = false, error = "request_failed" }) end) RegisterNUICallback("housing:command", function(data, cb) @@ -182,8 +205,8 @@ RegisterNUICallback("housing:command", function(data, cb) end local prepared = server_prepare(data.action, data) - if not prepared or not prepared.success or type(prepared.data) ~= "table" then - cb(prepared or { success = false, error = "request_failed" }) + if type(prepared) ~= "table" or not prepared.success or type(prepared.data) ~= "table" then + cb(type(prepared) == "table" and prepared or { success = false, error = "request_failed" }) return end if data.action == "set_waypoint" then @@ -214,6 +237,6 @@ end) AddEventHandler("onResourceStop", function(resource_name) if resource_name == GetCurrentResourceName() and camera_state then - stop_camera() + stop_camera(false) end end) diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index a0ef5fa..2e88d41 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -1,9 +1,17 @@ local is_open = false local open_requested = false local notification_focus = false +local call_focus = false +local payphone_focus = false +local camera_active = false +local camera_nui_focused = true local device_payload = nil local sim_picker_open = false +local sim_picker_payload = nil +local active_call_payload = nil local call_channel = 0 +local nui_generation = 0 +local activity_suspended = false Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true }) @@ -256,6 +264,46 @@ local function get_locale() return Locales[Config.Bridge.Locale] or Locales["en"] end +local function update_nui_focus() + local focus = SkyPhoneFocus.Resolve({ + activity_suspended = activity_suspended, + call_focus = call_focus, + camera_active = camera_active, + camera_nui_focused = camera_nui_focused, + is_open = is_open, + notification_focus = notification_focus, + payphone_focus = payphone_focus, + sim_picker_open = sim_picker_open, + }) + SetNuiFocus(focus.focused, focus.focused) + SetNuiFocusKeepInput(focus.keep_input) + TriggerEvent("sky_phone:client:cameraFocusApplied", { + active = camera_active, + focused = focus.focused, + gameInput = focus.keep_input, + }) +end + +AddEventHandler("sky_phone:client:setSuspended", function(suspended) + activity_suspended = suspended == true + update_nui_focus() +end) + +AddEventHandler("sky_phone:client:setPayphoneFocus", function(focused) + payphone_focus = focused == true + update_nui_focus() +end) + +AddEventHandler("sky_phone:client:setCameraFocus", function(data) + if type(data) ~= "table" or type(data.active) ~= "boolean" or type(data.nuiFocused) ~= "boolean" then + Bridge.Debug("error", "[sky_phone] Rejected invalid camera focus claim.") + return + end + camera_active = data.active + camera_nui_focused = data.nuiFocused + update_nui_focus() +end) + local function send_open_message() if not device_payload then return @@ -279,21 +327,31 @@ local function open_phone() send_open_message() end -local function close_phone() +local function close_phone(close_device_session) + local was_requested = open_requested + local was_open = is_open open_requested = false + call_focus = false + activity_suspended = false TriggerEvent("sky_phone:animation:phone", false) - if not is_open then - return - end - is_open = false - SkyPhoneApps.SetPhoneOpen(false) - TriggerEvent("sky_phone:nuiClosed") - SetNuiFocus(notification_focus or sim_picker_open, notification_focus or sim_picker_open) - SendNUIMessage({ type = "app:close" }) - Bridge.Callbacks.Trigger("sky_phone:device:close", {}) + if was_open then + SkyPhoneApps.SetPhoneOpen(false) + TriggerEvent("sky_phone:nuiClosed") + end + update_nui_focus() + if was_requested or was_open then + SendNUIMessage({ type = "app:close" }) + if close_device_session ~= false then + Bridge.Callbacks.Trigger("sky_phone:device:close", {}) + end + end end +AddEventHandler("sky_phone:client:forceClose", function() + close_phone() +end) + local function leave_call_voice() if call_channel == 0 then return @@ -328,24 +386,67 @@ if Config.Phone.DevelopmentCommand then end, false) end -RegisterNUICallback("ui:ready", function(_, cb) +RegisterNetEvent("sky_phone:testdata:feedback", function(success, detail) + local locale = get_locale().TestData + local message = success and locale.Success or locale.Failed + if success and type(detail) == "string" and detail ~= "" then + message = message:gsub("{email}", detail) + end + Bridge.Framework.Notify("iFruit", message, success and "success" or "error", 7000) +end) + +RegisterNUICallback("ui:ready", function(data, cb) + if type(data) ~= "table" or data.protocolVersion ~= 1 then + cb({ success = false, error = "unsupported_protocol" }) + return + + end + nui_generation = nui_generation + 1 + -- Browser state is recreated on a CEF reload. A notification focus claim + -- cannot survive unless its notification is replayed as part of this handshake. + notification_focus = false Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true }) - TriggerEvent("sky_phone:client:nuiReady") SkyPhoneApps.SendCatalog() if open_requested and device_payload then send_open_message() end + if active_call_payload then + SendNUIMessage({ type = "call:state", data = active_call_payload }) + end + if sim_picker_open and sim_picker_payload then + SendNUIMessage({ type = "sim:picker", data = sim_picker_payload }) + else + SendNUIMessage({ type = "sim:picker-close" }) + end - cb({ success = true }) + update_nui_focus() + TriggerEvent("sky_phone:client:nuiReady", { + generation = nui_generation, + protocolVersion = 1, + }) + + cb({ + success = true, + data = { + generation = nui_generation, + protocolVersion = 1, + }, + }) end) -RegisterNUICallback("ui:opened", function(_, cb) +RegisterNUICallback("ui:opened", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end if not open_requested or not device_payload then Bridge.Debug( "warn", "[sky_phone] Ignored a NUI open confirmation without a pending device open.", { always = true } ) + SendNUIMessage({ type = "app:close" }) + update_nui_focus() cb({ success = false, error = "open_not_requested" }) return end @@ -353,29 +454,49 @@ RegisterNUICallback("ui:opened", function(_, cb) is_open = true SkyPhoneApps.SetPhoneOpen(true) notification_focus = false - SetNuiFocus(true, true) + call_focus = false + update_nui_focus() TriggerEvent("sky_phone:animation:phone", true) cb({ success = true }) end) -RegisterNUICallback("close", function(_, cb) +RegisterNUICallback("close", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end close_phone() cb({ success = true }) end) RegisterNUICallback("notification:focus", function(data, cb) + if type(data) ~= "table" or type(data.active) ~= "boolean" then + cb({ success = false, error = "invalid_request" }) + return + end notification_focus = data.active == true and not is_open - SetNuiFocus(is_open or notification_focus, is_open or notification_focus) + update_nui_focus() cb({ success = true }) end) -RegisterNUICallback("sim:picker-close", function(_, cb) +RegisterNUICallback("sim:picker-close", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end sim_picker_open = false - SetNuiFocus(is_open or notification_focus, is_open or notification_focus) - cb({ success = true }) + sim_picker_payload = nil + update_nui_focus() + SendNUIMessage({ type = "sim:picker-close" }) + local result = Bridge.Callbacks.Trigger("sky_phone:sim:picker-close", {}) + cb(type(result) == "table" and result or { success = false, error = "request_failed" }) end) -RegisterNUICallback("map:getPlayerCoords", function(_, cb) +RegisterNUICallback("map:getPlayerCoords", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end local coords = GetEntityCoords(PlayerPedId()) cb({ success = true, @@ -430,7 +551,11 @@ local function weather_region(coords) return "los_santos" end -RegisterNUICallback("weather:get", function(_, cb) +RegisterNUICallback("weather:get", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end local coords = GetEntityCoords(PlayerPedId()) local weather_hash = GetPrevWeatherTypeHashName() local next_weather_hash = GetNextWeatherTypeHashName() @@ -470,9 +595,13 @@ local function garage_vehicle_kind(model_hash, fallback) end RegisterNUICallback("garage:vehicles", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end local result = Bridge.Callbacks.Trigger("sky_phone:garage:vehicles", data) - if not result or not result.success or type(result.data) ~= "table" then - cb(result or { success = false, error = "request_failed" }) + if type(result) ~= "table" or not result.success or type(result.data) ~= "table" then + cb(type(result) == "table" and result or { success = false, error = "request_failed" }) return end for _, vehicle in ipairs(result.data.vehicles or {}) do @@ -496,8 +625,12 @@ end) for _, callback_name in ipairs(server_callbacks) do RegisterNUICallback(callback_name, function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data) - if result then + if type(result) == "table" then cb(result) return end @@ -507,6 +640,10 @@ for _, callback_name in ipairs(server_callbacks) do end RegisterNetEvent("sky_phone:device:open", function(data) + if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then + Bridge.Debug("error", "[sky_phone] Rejected invalid device open data.") + return + end Bridge.Debug( "debug", "[sky_phone] Client received device open for IMEI %s account_linked=%s.", @@ -516,19 +653,27 @@ RegisterNetEvent("sky_phone:device:open", function(data) ) device_payload = data open_requested = true + if is_open then + SkyPhoneApps.SendCatalog() + SendNUIMessage({ type = "device:updated", data = data }) + return + end open_phone() end) RegisterNetEvent("sky_phone:device:updated", function(data) + if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then + Bridge.Debug("error", "[sky_phone] Rejected invalid device update data.") + return + end device_payload = data SendNUIMessage({ type = "device:updated", data = data }) end) RegisterNetEvent("sky_phone:device:invalidated", function() - open_requested = false - device_payload = nil TriggerEvent("sky_phone:animation:reset") - close_phone() + close_phone(false) + device_payload = nil end) RegisterNetEvent("sky_phone:device:error", function(error_code) @@ -660,14 +805,20 @@ RegisterNetEvent("sky_phone:flare:message", function(data) end) RegisterNetEvent("sky_phone:sim:picker", function(data) + if type(data) ~= "table" or type(data.number) ~= "string" or type(data.choices) ~= "table" then + Bridge.Debug("error", "[sky_phone] Rejected invalid SIM picker data.") + return + end sim_picker_open = true - SetNuiFocus(true, true) + sim_picker_payload = data + update_nui_focus() SendNUIMessage({ type = "sim:picker", data = data }) end) RegisterNetEvent("sky_phone:sim:picker-close", function() sim_picker_open = false - SetNuiFocus(is_open or notification_focus, is_open or notification_focus) + sim_picker_payload = nil + update_nui_focus() SendNUIMessage({ type = "sim:picker-close" }) end) @@ -743,13 +894,35 @@ RegisterNetEvent("sky_phone:darkchat:new", function(data) end) RegisterNetEvent("sky_phone:call:incoming", function(data) - notification_focus = true - SetNuiFocus(true, true) + if type(data) ~= "table" or type(data.id) ~= "string" or data.state ~= "ringing" then + Bridge.Debug("error", "[sky_phone] Rejected invalid incoming call data.") + return + end + active_call_payload = data + call_focus = true + update_nui_focus() TriggerEvent("sky_phone:animation:call", data) SendNUIMessage({ type = "call:incoming", data = data }) end) RegisterNetEvent("sky_phone:call:state", function(data) + if type(data) ~= "table" or type(data.id) ~= "string" or type(data.state) ~= "string" then + Bridge.Debug("error", "[sky_phone] Rejected invalid call state data.") + return + end + if data.state == "ringing" or data.state == "connected" then + active_call_payload = data + end + if data.state ~= "ringing" then + local focus_changed = call_focus + call_focus = false + if focus_changed then + update_nui_focus() + end + end + if data.state ~= "ringing" and data.state ~= "connected" then + active_call_payload = nil + end if data.state == "connected" and data.channel then if not join_call_voice(data.channel) then TriggerEvent("sky_phone:device:error", "voice_unavailable") @@ -765,6 +938,9 @@ CreateThread(function() if Config.Phone.DevelopmentCommand then TriggerEvent("chat:addSuggestion", "/" .. Config.Command, get_locale().CommandDescription) end + if Config.TestData.Enabled then + TriggerEvent("chat:addSuggestion", "/" .. Config.TestData.Command, get_locale().TestData.CommandDescription) + end end) AddEventHandler("onResourceStop", function(resource_name) @@ -772,9 +948,19 @@ AddEventHandler("onResourceStop", function(resource_name) return end - if is_open or notification_focus then - SetNuiFocus(false, false) - end + is_open = false + open_requested = false + notification_focus = false + call_focus = false + payphone_focus = false + camera_active = false + camera_nui_focused = true + sim_picker_open = false + sim_picker_payload = nil + active_call_payload = nil + activity_suspended = false + SetNuiFocusKeepInput(false) + SetNuiFocus(false, false) TriggerEvent("sky_phone:animation:reset") leave_call_voice() @@ -782,4 +968,7 @@ AddEventHandler("onResourceStop", function(resource_name) if Config.Phone.DevelopmentCommand then TriggerEvent("chat:removeSuggestion", "/" .. Config.Command) end + if Config.TestData.Enabled then + TriggerEvent("chat:removeSuggestion", "/" .. Config.TestData.Command) + end end) diff --git a/sky_phone/source/client/payphones.lua b/sky_phone/source/client/payphones.lua index d3fb3b4..d59b569 100644 --- a/sky_phone/source/client/payphones.lua +++ b/sky_phone/source/client/payphones.lua @@ -6,6 +6,7 @@ local active_call_state = nil local active_call_number = nil local active_call_elapsed_seconds = 0 local active_call_elapsed_updated_at = 0 +local active_call_payload = nil local call_channel = 0 local replacement_prop = nil local hidden_prop = nil @@ -360,11 +361,20 @@ local function booth_payload(booth) } end +local function payphone_open_payload() + return { + currency = Config.Payphones.Currency, + maxNumberLength = Config.Sim.NumberLength, + pricePerSecond = Config.Payphones.PricePerSecond, + locales = get_locale().Nui.Payphone, + } +end + local function close_payphone() local was_open = payphone_open payphone_open = false if was_open then - SetNuiFocus(false, false) + TriggerEvent("sky_phone:client:setPayphoneFocus", false) SendNUIMessage({ type = "payphone:close" }) end if not active_call_id then @@ -418,6 +428,7 @@ local function apply_active_call_state(data) active_call_elapsed_seconds = 0 active_call_elapsed_updated_at = 0 end + active_call_payload = data end local function clear_active_call_state() @@ -426,6 +437,7 @@ local function clear_active_call_state() active_call_number = nil active_call_elapsed_seconds = 0 active_call_elapsed_updated_at = 0 + active_call_payload = nil hangup_requested = false end @@ -435,48 +447,51 @@ local function open_payphone(booth) end active_booth = booth payphone_open = true - SetNuiFocus(true, true) + TriggerEvent("sky_phone:client:setPayphoneFocus", true) SendNUIMessage({ type = "payphone:open", - data = { - currency = Config.Payphones.Currency, - maxNumberLength = Config.Sim.NumberLength, - pricePerSecond = Config.Payphones.PricePerSecond, - locales = get_locale().Nui.Payphone, - }, + data = payphone_open_payload(), }) end RegisterNUICallback("payphone:dial", function(data, cb) - if not payphone_open or not active_booth or active_call_id then + if type(data) ~= "table" or not payphone_open or not active_booth or active_call_id then cb({ success = false, error = "invalid_request" }) return end local payload = booth_payload(active_booth) - payload.phoneNumber = type(data) == "table" and data.phoneNumber or nil + payload.phoneNumber = data.phoneNumber local result = Bridge.Callbacks.Trigger("sky_phone:payphone:dial", payload) - local call_started = result and result.success and result.data + local call_started = type(result) == "table" and result.success and type(result.data) == "table" and (result.data.state == "ringing" or result.data.state == "connected") if call_started then apply_active_call_state(result.data) end - cb(result or { success = false, error = "request_failed" }) + cb(type(result) == "table" and result or { success = false, error = "request_failed" }) if call_started then close_payphone() start_call_visuals() end end) -RegisterNUICallback("payphone:hangup", function(_, cb) +RegisterNUICallback("payphone:hangup", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end if not active_call_id then cb({ success = false, error = "call_not_found" }) return end local result = Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id }) - cb(result or { success = false, error = "request_failed" }) + cb(type(result) == "table" and result or { success = false, error = "request_failed" }) end) -RegisterNUICallback("payphone:close", function(_, cb) +RegisterNUICallback("payphone:close", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end if active_call_id then Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id }) end @@ -485,7 +500,7 @@ RegisterNUICallback("payphone:close", function(_, cb) end) RegisterNetEvent("sky_phone:payphone:state", function(data) - if type(data) ~= "table" then + if type(data) ~= "table" or type(data.id) ~= "string" or type(data.state) ~= "string" then return end if data.state == "ringing" or data.state == "connected" then @@ -506,6 +521,24 @@ RegisterNetEvent("sky_phone:payphone:state", function(data) SendNUIMessage({ type = "payphone:state", data = data }) end) +AddEventHandler("sky_phone:client:nuiReady", function() + if payphone_open then + TriggerEvent("sky_phone:client:setPayphoneFocus", true) + SendNUIMessage({ type = "payphone:open", data = payphone_open_payload() }) + else + TriggerEvent("sky_phone:client:setPayphoneFocus", false) + SendNUIMessage({ type = "payphone:close" }) + end + if active_call_payload then + local payload = {} + for key, value in pairs(active_call_payload) do + payload[key] = value + end + payload.elapsedSeconds = current_call_elapsed_seconds() + SendNUIMessage({ type = "payphone:state", data = payload }) + end +end) + CreateThread(function() while true do if not Config.Payphones.Enabled or payphone_open or active_call_id or visuals_ending then @@ -625,9 +658,7 @@ AddEventHandler("onResourceStop", function(resource_name) if resource_name ~= GetCurrentResourceName() then return end - if payphone_open then - SetNuiFocus(false, false) - end + TriggerEvent("sky_phone:client:setPayphoneFocus", false) leave_call_voice() stop_call_visuals() clear_active_call_state() diff --git a/sky_phone/source/client/radio.lua b/sky_phone/source/client/radio.lua index 0132f7d..2b087cf 100644 --- a/sky_phone/source/client/radio.lua +++ b/sky_phone/source/client/radio.lua @@ -120,7 +120,10 @@ local function join_radio(primary, secondary) return approved end - local data = approved.data or {} + if type(approved.data) ~= "table" then + return { success = false, error = "request_failed" } + end + local data = approved.data local approved_primary = tonumber(data.frequency) or 0 local approved_secondary = tonumber(data.secondaryFrequency) or 0 if not Bridge.Radio.Join(approved_primary, approved_secondary) then @@ -148,27 +151,41 @@ local function leave_radio() return request("disconnect") end -RegisterNUICallback("radio:get", function(_, cb) +RegisterNUICallback("radio:get", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end local result = request("get") - if result.success then + if result.success and type(result.data) == "table" then apply_server_state(result.data) result.data.volume = current_volume result.data.provider = Bridge.Radio.GetProvider() result.data.secondarySupported = Bridge.Radio.SupportsSecondary() + elseif result.success then + result = { success = false, error = "request_failed" } end cb(result) end) RegisterNUICallback("radio:connect", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end cb(join_radio(data.frequency, data.secondaryFrequency)) end) -RegisterNUICallback("radio:disconnect", function(_, cb) +RegisterNUICallback("radio:disconnect", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end cb(leave_radio()) end) RegisterNUICallback("radio:set-volume", function(data, cb) - local volume = tonumber(data.volume) + local volume = type(data) == "table" and tonumber(data.volume) or nil if not volume then cb({ success = false, error = "invalid_volume" }) return @@ -179,6 +196,10 @@ RegisterNUICallback("radio:set-volume", function(data, cb) end) RegisterNUICallback("radio:save-settings", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end local result = request("save-settings", data) if result.success then apply_server_state({ settings = result.data }) @@ -187,14 +208,25 @@ RegisterNUICallback("radio:save-settings", function(data, cb) end) RegisterNUICallback("radio:save-badge", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end cb(request("save-badge", data)) end) RegisterNUICallback("radio:save-display-name", function(data, cb) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end cb(request("save-display-name", data)) end) RegisterNetEvent("sky_phone:radio:members", function(data) + if type(data) ~= "table" then + return + end local frequency = tonumber(data.frequency) local channel_id = frequency == current_primary and 1 or frequency == current_secondary and 2 or nil if not channel_id then diff --git a/sky_phone/source/client/skyride.lua b/sky_phone/source/client/skyride.lua index abf5862..42240c4 100644 --- a/sky_phone/source/client/skyride.lua +++ b/sky_phone/source/client/skyride.lua @@ -88,7 +88,11 @@ end for index = 1, #server_callbacks do local callback_name = server_callbacks[index] RegisterNUICallback(callback_name, function(data, cb) - local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data or {}) + if type(data) ~= "table" then + cb({ success = false, error = "invalid_request" }) + return + end + local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data) if not result then cb({ success = false, error = "request_failed" }) return diff --git a/sky_phone/source/server/calls.lua b/sky_phone/source/server/calls.lua index 537306c..b81e731 100644 --- a/sky_phone/source/server/calls.lua +++ b/sky_phone/source/server/calls.lua @@ -1024,31 +1024,45 @@ end) local payphone_models = {} for _, model_name in ipairs(Config.Payphones.Props or {}) do - payphone_models[model_name] = true + if type(model_name) == "string" then + payphone_models[model_name] = true + end end -local function valid_payphone_position(source, data) - if type(data) ~= "table" or not payphone_models[data.model] or type(data.coords) ~= "table" then - return nil - end - local x = tonumber(data.coords.x) - local y = tonumber(data.coords.y) - local z = tonumber(data.coords.z) - if not x or not y or not z or x ~= x or y ~= y or z ~= z - or math.abs(x) > 10000.0 or math.abs(y) > 10000.0 or math.abs(z) > 2000.0 - then - return nil - end +local payphone_locations, rejected_payphone_locations = SkyPhonePayphones.ValidateLocations( + Config.Payphones.Locations, + payphone_models +) +if Config.Payphones.Enabled and #payphone_locations == 0 then + Bridge.Debug( + "error", + "[sky_phone] Payphones are enabled, but no valid server-owned locations are configured; payphone calls will be rejected.", + { always = true } + ) +elseif Config.Payphones.Enabled and rejected_payphone_locations > 0 then + Bridge.Debug( + "warn", + "[sky_phone] Ignored %s invalid server-owned payphone location(s).", + rejected_payphone_locations, + { always = true } + ) +end + +local function valid_payphone_position(source) local ped = GetPlayerPed(source) if not ped or ped == 0 then return nil end local player_coords = GetEntityCoords(ped) - local booth_coords = vector3(x, y, z) - if #(player_coords - booth_coords) > Config.Payphones.ServerValidationDistance then + local location = SkyPhonePayphones.FindNearest( + payphone_locations, + player_coords, + Config.Payphones.ServerValidationDistance + ) + if not location then return nil end - return booth_coords, data.model + return vector3(location.coords.x, location.coords.y, location.coords.z), location.model end local function payphone_terminal(number, state) @@ -1064,10 +1078,13 @@ local function payphone_terminal(number, state) end Bridge.Callbacks.Register("sky_phone:payphone:dial", function(source, data) + if type(data) ~= "table" then + return { success = false, error = "invalid_request" } + end if not Config.Payphones.Enabled or not SkyPhone.AllowOperation(source, "payphone_dial", 15, 60) then return { success = false, error = "rate_limited" } end - local booth_coords, booth_model = valid_payphone_position(source, data) + local booth_coords, booth_model = valid_payphone_position(source) if not booth_coords then return { success = false, error = "invalid_payphone" } end diff --git a/sky_phone/source/server/darkchat.lua b/sky_phone/source/server/darkchat.lua index 8b3ae20..1a4f8ee 100644 --- a/sky_phone/source/server/darkchat.lua +++ b/sky_phone/source/server/darkchat.lua @@ -538,12 +538,16 @@ Bridge.Callbacks.Register("sky_phone:darkchat:send", function(source, data) media_waveform = voice.waveform elseif message_type == "image" or message_type == "video" then local media_type = message_type == "image" and "photo" or "video" - local media_url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, data.mediaAssetId, media_type) + local media_url, media_error, resolved_mime = SkyPhoneMedia.ResolveOwnedMedia( + source, + data.mediaAssetId, + media_type + ) if not media_url then return { success = false, error = media_error } end media_payload = media_url - media_mime = message_type == "image" and "image/jpeg" or "video/mp4" + media_mime = resolved_mime elseif message_type == "share" then local share local share_error diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 8d91d03..6dfbf02 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -377,6 +377,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 = "mime_type", type = "VARCHAR(120) NULL" }, { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, }, primaryKey = "id", @@ -2527,6 +2528,37 @@ 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([[ + UPDATE `sky_phone_media` + SET `mime_type` = CASE + WHEN `media_type` = 'video' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.webm' THEN 'video/webm' + WHEN `media_type` = 'video' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.mp4' THEN 'video/mp4' + WHEN `media_type` = 'photo' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.png' THEN 'image/png' + WHEN `media_type` = 'photo' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.webp' THEN 'image/webp' + WHEN `media_type` = 'photo' AND ( + LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.jpg' + OR LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.jpeg' + ) THEN 'image/jpeg' + ELSE NULL + END + WHERE `mime_type` IS NULL OR `mime_type` = '' +]], {}) +Bridge.Database.Query([[ + UPDATE `sky_phone_sms_messages` message + INNER JOIN `sky_phone_media` media ON media.`url` = message.`media_payload` + SET message.`media_mime` = media.`mime_type` + WHERE message.`message_type` = 'video' + AND media.`mime_type` IN ('video/webm', 'video/mp4') + AND (message.`media_mime` IS NULL OR message.`media_mime` <> media.`mime_type`) +]], {}) +Bridge.Database.Query([[ + UPDATE `sky_phone_darkchat_messages` message + INNER JOIN `sky_phone_media` media ON media.`url` = message.`media_payload` + SET message.`media_mime` = media.`mime_type` + WHERE message.`message_type` = 'video' + AND media.`mime_type` IN ('video/webm', 'video/mp4') + AND (message.`media_mime` IS NULL OR message.`media_mime` <> media.`mime_type`) +]], {}) Bridge.Database.Query([[ ALTER TABLE `sky_phone_marketplace_images` MODIFY COLUMN `gradient` VARCHAR(2200) NOT NULL diff --git a/sky_phone/source/server/media.lua b/sky_phone/source/server/media.lua index c6318b3..b7418da 100644 --- a/sky_phone/source/server/media.lua +++ b/sky_phone/source/server/media.lua @@ -3,14 +3,32 @@ SkyPhoneMedia = {} local pending_uploads = {} local pending_deletes = {} +local allowed_remote_mimes = { + photo = { + ["image/jpeg"] = true, + ["image/png"] = true, + ["image/webp"] = true, + }, + video = { + ["video/mp4"] = true, + ["video/webm"] = true, + }, +} local function media_config() return Config.Media.FiveManage end +local function media_api_key() + local convar = media_config().ApiKeyConvar + if type(convar) ~= "string" or convar == "" then + return "" + end + return GetConvar(convar, "") +end + local function api_configured() - local api_key = media_config().ApiKey - return type(api_key) == "string" and api_key ~= "" and api_key ~= "YOUR_API_TOKEN" + return media_api_key() ~= "" end local function http_request(url, method, body, headers, timeout_ms) @@ -63,7 +81,7 @@ local function request_presigned_url() tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url", "GET", "", - { ["Authorization"] = config.ApiKey }, + { ["Authorization"] = media_api_key() }, tonumber(config.RequestTimeoutMs) or 10000 ) local data, response_error = decode_response(response) @@ -86,7 +104,7 @@ local function get_remote_file(remote_id) ("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id), "GET", "", - { ["Authorization"] = config.ApiKey }, + { ["Authorization"] = media_api_key() }, tonumber(config.RequestTimeoutMs) or 10000 ) return decode_response(response) @@ -101,7 +119,7 @@ local function delete_remote_file(remote_id) ("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id), "DELETE", "", - { ["Authorization"] = config.ApiKey }, + { ["Authorization"] = media_api_key() }, tonumber(config.RequestTimeoutMs) or 10000 ) if response.status < 200 or response.status >= 300 then @@ -153,7 +171,7 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type) params[#params + 1] = value end local rows = Bridge.Database.Query(([[ - SELECT `url`, `media_type` FROM `sky_phone_media` + SELECT `url`, `media_type`, `mime_type` FROM `sky_phone_media` WHERE `id` = ? AND %s LIMIT 1 ]]):format(condition), params) @@ -172,7 +190,7 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type) ) return nil, "invalid_attachment" end - return media.url + return media.url, nil, media.mime_type end local function upload_result(source, correlation_id, success, error_code, media) @@ -224,18 +242,29 @@ 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 remote_type = tostring(remote.type or remote.mimeType or ""):lower() + 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 + 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" end if state.media_type == "video" and remote_type ~= "" and not remote_type:find("video", 1, true) then return nil, "invalid_media_type" end + if remote_mime ~= "" and not allowed_remote_mimes[state.media_type][remote_mime] then + return nil, "invalid_media_type" + end local metadata = parse_metadata(remote.metadata) if not metadata or metadata.captureToken ~= state.capture_token then return nil, "invalid_upload_token" end return { + mime_type = allowed_remote_mimes[state.media_type][remote_mime] and remote_mime or state.mime_type, remote_id = remote_id, url = remote.url or uploaded_url, } @@ -255,7 +284,7 @@ Bridge.Callbacks.Register("sky_phone:gallery:list", function(source, data) if not owner then return error_response end - data = data or {} + data = type(data) == "table" and data or {} local limit = math.max(1, math.min(math.floor(tonumber(data.limit) or Config.Media.PageSize), 100)) local offset = math.max(0, math.floor(tonumber(data.offset) or 0)) local media_type = data.mediaType @@ -342,7 +371,7 @@ 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 = Config.Media.GiphyApiKey + local api_key = GetConvar(Config.Media.GiphyApiKeyConvar, "") if api_key == "" then return { success = false, error = "gif_provider_unconfigured" } end @@ -420,7 +449,7 @@ end) RegisterNetEvent("sky_phone:media:request-upload", function(data) local src = source - data = data or {} + data = type(data) == "table" and data or {} local correlation_id = data.correlationId local media_type = data.mediaType if type(correlation_id) ~= "string" or #correlation_id > 80 @@ -454,6 +483,9 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data) capture_token = capture_token, correlation_id = correlation_id, media_type = media_type, + mime_type = media_type == "video" and "video/webm" + or ({ png = "image/png", webp = "image/webp" })[tostring(Config.Media.Photo.Encoding):lower()] + or "image/jpeg", owner = owner, source = src, } @@ -474,7 +506,7 @@ end) RegisterNetEvent("sky_phone:media:complete-upload", function(data) local src = source - data = data or {} + data = type(data) == "table" and data or {} local request_id = data.requestId local state = type(request_id) == "string" and pending_uploads[request_id] or nil if not state or state.source ~= src or state.completing then @@ -496,14 +528,14 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data) local result if owner.account_id then result = Bridge.Database.Query([[ - INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`) - VALUES (?, NULL, ?, ?, ?) - ]], { owner.account_id, verified.url, verified.remote_id, state.media_type }) + INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`) + VALUES (?, NULL, ?, ?, ?, ?) + ]], { owner.account_id, verified.url, verified.remote_id, state.media_type, verified.mime_type }) else result = Bridge.Database.Query([[ - INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`) - VALUES (NULL, ?, ?, ?, ?) - ]], { owner.imei, verified.url, verified.remote_id, state.media_type }) + INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`) + VALUES (NULL, ?, ?, ?, ?, ?) + ]], { owner.imei, verified.url, verified.remote_id, state.media_type, verified.mime_type }) end pending_uploads[request_id] = nil local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId)) @@ -522,7 +554,7 @@ end) RegisterNetEvent("sky_phone:media:cancel-upload", function(data) local src = source - local request_id = data and data.requestId + 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 @@ -532,7 +564,7 @@ end) RegisterNetEvent("sky_phone:media:fail-upload", function(data) local src = source - local request_id = data and data.requestId + 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 @@ -550,7 +582,7 @@ end) RegisterNetEvent("sky_phone:media:delete", function(data) local src = source - data = data or {} + data = type(data) == "table" and data or {} local correlation_id = data.correlationId local media_id = tonumber(data.id) if type(correlation_id) ~= "string" or #correlation_id > 80 or not media_id then @@ -646,6 +678,7 @@ AddEventHandler("playerDropped", function() end) if not api_configured() then - print("^3[sky_phone] Camera and Gallery uploads are disabled until Config.Media.FiveManage.ApiKey is set in config/media.lua.^7") + print(("^3[sky_phone] Camera and Gallery uploads are disabled until the %s server convar is set.^7") + :format(tostring(media_config().ApiKeyConvar))) end end) diff --git a/sky_phone/source/server/messages.lua b/sky_phone/source/server/messages.lua index 6516f0d..f0868a6 100644 --- a/sky_phone/source/server/messages.lua +++ b/sky_phone/source/server/messages.lua @@ -32,7 +32,7 @@ local attachment_assets = { local attachment_mimes = { gif = "image/gif", image = "image/jpeg", - video = "video/mp4", + video = "video/webm", } local function allowed_media_url(value) @@ -208,7 +208,9 @@ local function validate_attachment(source, device, message_type, data) return nil end local payload = data.mediaAssetId - if not valid_attachment_asset(message_type, payload) then + local built_in_asset = valid_attachment_asset(message_type, payload) + local mime = built_in_asset and attachment_mimes[message_type] or nil + if not built_in_asset then if message_type == "gif" then return nil end @@ -226,7 +228,7 @@ local function validate_attachment(source, device, message_type, data) params = { media_id, device.imei } end local rows = Bridge.Database.Query(([[ - SELECT `url`, `media_type` FROM `sky_phone_media` + SELECT `url`, `media_type`, `mime_type` FROM `sky_phone_media` WHERE `id` = ? AND %s LIMIT 1 ]]):format(condition), params) @@ -241,6 +243,7 @@ local function validate_attachment(source, device, message_type, data) return nil end payload = media.url + mime = type(media.mime_type) == "string" and media.mime_type ~= "" and media.mime_type or nil end local duration = nil if message_type == "video" and data.mediaDurationMs ~= nil then @@ -252,7 +255,7 @@ local function validate_attachment(source, device, message_type, data) end return { duration = duration, - mime = attachment_mimes[message_type], + mime = mime, payload = payload, } end diff --git a/sky_phone/source/server/payphones.lua b/sky_phone/source/server/payphones.lua new file mode 100644 index 0000000..3a1fa47 --- /dev/null +++ b/sky_phone/source/server/payphones.lua @@ -0,0 +1,105 @@ +SkyPhonePayphones = {} + +local maximum_horizontal_coordinate = 10000.0 +local maximum_vertical_coordinate = 2000.0 + +local function finite_number(value) + if type(value) ~= "number" or value ~= value or value == math.huge or value == -math.huge then + return nil + end + return value +end + +local function normalize_coordinates(value, allow_vector) + local value_type = type(value) + if value_type ~= "table" and (not allow_vector or value_type ~= "vector3") then + return nil + end + + local x = finite_number(value.x) + local y = finite_number(value.y) + local z = finite_number(value.z) + if not x or not y or not z + or math.abs(x) > maximum_horizontal_coordinate + or math.abs(y) > maximum_horizontal_coordinate + or math.abs(z) > maximum_vertical_coordinate + then + return nil + end + + return { x = x, y = y, z = z } +end + +local function normalize_location(location, allowed_models) + if type(location) ~= "table" or type(location.model) ~= "string" or not allowed_models[location.model] then + return nil + end + + local coords = normalize_coordinates(location.coords, false) + if not coords then + return nil + end + + return { + model = location.model, + coords = coords, + } +end + +function SkyPhonePayphones.ValidateLocations(locations, allowed_models) + if type(locations) ~= "table" or type(allowed_models) ~= "table" then + return {}, 0 + end + + local validated = {} + local rejected = 0 + for index, location in pairs(locations) do + local valid_index = type(index) == "number" and index >= 1 and index % 1 == 0 + local normalized = valid_index and normalize_location(location, allowed_models) or nil + if normalized then + normalized.index = index + validated[#validated + 1] = normalized + else + rejected = rejected + 1 + end + end + + table.sort(validated, function(left, right) + return left.index < right.index + end) + for index = 1, #validated do + validated[index].index = nil + end + + return validated, rejected +end + +function SkyPhonePayphones.FindNearest(locations, player_coords, maximum_distance) + if type(locations) ~= "table" then + return nil + end + + local coords = normalize_coordinates(player_coords, true) + local distance = finite_number(maximum_distance) + if not coords or not distance or distance <= 0 then + return nil + end + + local nearest = nil + local nearest_distance_squared = distance * distance + for index = 1, #locations do + local location = locations[index] + if type(location) == "table" and type(location.coords) == "table" then + local dx = coords.x - location.coords.x + local dy = coords.y - location.coords.y + local dz = coords.z - location.coords.z + local distance_squared = dx * dx + dy * dy + dz * dz + if distance_squared <= nearest_distance_squared then + nearest = location + nearest_distance_squared = distance_squared + end + end + end + + return nearest +end diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index 29ad218..827caf8 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -915,15 +915,20 @@ function SkyPhone.OpenDeviceForCall(source, imei) return false end local security = load_device_security(imei) - if sessions[source] and sessions[source].imei ~= imei then + local existing_session = sessions[source] + if existing_session and existing_session.imei ~= imei then SkyPhoneCompanies.ClearCallAvailability(source) end - sessions[source] = { - imei = imei, - slot = matches[1].slot, - token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())), - unlocked = security == nil, - } + if existing_session and existing_session.imei == imei then + existing_session.slot = matches[1].slot + else + sessions[source] = { + imei = imei, + slot = matches[1].slot, + token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())), + unlocked = security == nil, + } + end TriggerClientEvent("sky_phone:device:open", source, bootstrap(source)) return true end @@ -1076,7 +1081,13 @@ Bridge.Callbacks.Register("sky_phone:device:save", function(source, data) if not session then return error_response end - if type(data) ~= "table" or not allowed_device_namespaces[data.namespace] then + if type(data) ~= "table" then + return { success = false, error = "invalid_request" } + end + if data.imei ~= session.imei or data.sessionToken ~= session.token then + return { success = false, error = "stale_session" } + end + if not allowed_device_namespaces[data.namespace] then return { success = false, error = "invalid_namespace" } end diff --git a/sky_phone/source/server/sim.lua b/sky_phone/source/server/sim.lua index 34bde46..fe6f3c4 100644 --- a/sky_phone/source/server/sim.lua +++ b/sky_phone/source/server/sim.lua @@ -406,6 +406,14 @@ Bridge.Callbacks.Register("sky_phone:sim:insert", function(source, data) return insert_sim(source, data.imei, data.confirmed == true) end) +Bridge.Callbacks.Register("sky_phone:sim:picker-close", function(source) + if operation_locks[source] then + return { success = false, error = "operation_in_progress" } + end + pending_insertions[source] = nil + return { success = true } +end) + Bridge.Callbacks.Register("sky_phone:sim:eject", function(source) if not sim_cards_enabled then return { success = false, error = "disabled" } diff --git a/sky_phone/source/server/testdata.lua b/sky_phone/source/server/testdata.lua new file mode 100644 index 0000000..49864b7 --- /dev/null +++ b/sky_phone/source/server/testdata.lua @@ -0,0 +1,964 @@ +Bridge.Database.AfterMigration("sky_phone", function() + +if not Config.TestData.Enabled then + return +end + +local photo_urls = { + city = "https://images.unsplash.com/photo-1519501025264-65ba15a82390?auto=format&fit=crop&w=1200&q=80", + car = "https://images.unsplash.com/photo-1493238792000-8113da705763?auto=format&fit=crop&w=1200&q=80", + beach = "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=1200&q=80", + portrait = "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=800&q=80", +} +local video_url = "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" +local seed_attempts = {} + +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 stable_uuid(seed) + local rows = Bridge.Database.Query([[ + SELECT LOWER(CONCAT( + SUBSTR(MD5(?), 1, 8), '-', SUBSTR(MD5(?), 9, 4), '-', + SUBSTR(MD5(?), 13, 4), '-', SUBSTR(MD5(?), 17, 4), '-', SUBSTR(MD5(?), 21, 12) + )) AS `id` + ]], { seed, seed, seed, seed, seed }) + local id = rows[1] and rows[1].id + if type(id) ~= "string" or #id ~= 36 then + error("[sky_phone] Test data could not generate a stable UUID.") + end + return id +end + +local function seed_hash(seed) + local rows = Bridge.Database.Query("SELECT LEFT(MD5(?), 12) AS `value`", { seed }) + local value = rows[1] and rows[1].value + if type(value) ~= "string" or #value ~= 12 then + error("[sky_phone] Test data could not generate a stable account suffix.") + end + return value +end + +local function database_uuid() + local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) + local id = rows[1] and rows[1].id + if type(id) ~= "string" then + error("[sky_phone] Test data could not generate a database UUID.") + end + return id +end + +local function ensure_account(email) + Bridge.Database.Query( + "INSERT IGNORE INTO `sky_phone_accounts` (`email`, `password`) VALUES (?, ?)", + { email, "sky-phone-test" } + ) + local rows = Bridge.Database.Query( + "SELECT `id`, `email` FROM `sky_phone_accounts` WHERE `email` = ? LIMIT 1", + { email } + ) + if not rows[1] then + error(("[sky_phone] Test data account '%s' could not be loaded."):format(email)) + end + rows[1].id = tonumber(rows[1].id) + return rows[1] +end + +local function ensure_media(account_id, remote_id, url, media_type) + local rows = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_media` + WHERE `account_id` = ? AND `remote_id` = ? + ORDER BY `id` LIMIT 1 + ]], { account_id, remote_id }) + if rows[1] then + Bridge.Database.Query( + "UPDATE `sky_phone_media` SET `url` = ?, `media_type` = ? WHERE `id` = ?", + { url, media_type, rows[1].id } + ) + return tonumber(rows[1].id) + end + Bridge.Database.Query([[ + INSERT INTO `sky_phone_media` (`account_id`, `url`, `remote_id`, `media_type`) + VALUES (?, ?, ?, ?) + ]], { account_id, url, remote_id, media_type }) + rows = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_media` + WHERE `account_id` = ? AND `remote_id` = ? + ORDER BY `id` DESC LIMIT 1 + ]], { account_id, remote_id }) + if not rows[1] then + error("[sky_phone] Test media could not be created.") + end + return tonumber(rows[1].id) +end + +local function reserve_sim(owner_identifier, firstname, lastname) + local rows = Bridge.Database.Query([[ + SELECT `id`, `phone_number`, `sim_type` + FROM `sky_phone_sims` + WHERE `owner_identifier` = ? AND `is_virtual` = 0 + ORDER BY `created_at` LIMIT 1 + ]], { owner_identifier }) + if rows[1] then + return rows[1] + end + + local sim_id + local number = SkyPhoneSimNumber.Reserve(database_uuid, function(candidate) + if SkyPhoneCompanies.IsServiceNumber(candidate) then + return false + end + sim_id = database_uuid() + local result = Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_sims` + (`id`, `phone_number`, `sim_type`, `is_virtual`, `owner_identifier`, + `owner_firstname`, `owner_lastname`, `registered_at`) + VALUES (?, ?, 'registered', 0, ?, ?, ?, CURRENT_TIMESTAMP) + ]], { sim_id, candidate, owner_identifier, firstname, lastname }) + return affected_rows(result) == 1 + end, Config.Sim.NumberLength, Config.Sim.NumberPrefix) + if not number then + error("[sky_phone] Test data could not reserve a SIM number.") + end + return { id = sim_id, phone_number = number, sim_type = "registered" } +end + +local function ensure_bot(label, email_local, imei, firstname, lastname) + local account = ensure_account(email_local .. "@" .. Config.Mail.Domain) + local sim = reserve_sim("sky_phone:testbot:" .. label, firstname, lastname) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_devices` (`imei`, `account_id`, `sim_id`, `device_name`) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE `account_id` = VALUES(`account_id`), `sim_id` = VALUES(`sim_id`) + ]], { imei, account.id, sim.id, firstname .. "'s iFruit" }) + return { + account = account, + sim = sim, + imei = imei, + name = firstname .. " " .. lastname, + } +end + +local function ensure_numeric_profile(table_name, account_id) + local rows = Bridge.Database.Query( + ("SELECT `id` FROM `%s` WHERE `account_id` = ? LIMIT 1"):format(table_name), + { account_id } + ) + if not rows[1] then + error(("[sky_phone] Test profile in '%s' could not be loaded."):format(table_name)) + end + return tonumber(rows[1].id) +end + +local function ensure_string_profile(table_name, account_id) + local rows = Bridge.Database.Query( + ("SELECT `id` FROM `%s` WHERE `account_id` = ? LIMIT 1"):format(table_name), + { account_id } + ) + if not rows[1] or type(rows[1].id) ~= "string" then + error(("[sky_phone] Test profile in '%s' could not be loaded."):format(table_name)) + end + return rows[1].id +end + +local function seed_core(context) + local account_id = context.account.id + local bot = context.bot_one + local alarms = { + { + id = "test-weekday", + enabled = true, + time = "07:30", + note = "Phone QA", + sound = "radar", + weekdays = { 1, 2, 3, 4, 5 }, + lastTriggeredMinute = nil, + }, + { + id = "test-weekend", + enabled = false, + time = "10:00", + note = "Car Meet", + sound = "chimes", + weekdays = { 0, 6 }, + lastTriggeredMinute = nil, + }, + } + local games = { + snake = { highScore = 42, speed = "fast" }, + memory = { + best = { + small = { moves = 12, timeMs = 42000 }, + medium = { moves = 28, timeMs = 96000 }, + }, + soundEnabled = true, + }, + minesweeper = { + best = { quick = { timeMs = 31000 }, classic = { timeMs = 124000 } }, + elapsedMs = 0, + game = nil, + soundEnabled = true, + }, + ["number-merge"] = { bestScore = 8192, game = nil, highestTile = 1024, soundEnabled = true }, + ["tower-stack"] = { highHeight = 23, highScore = 4750, soundEnabled = true }, + ["sky-flappy"] = { design = "neon", highScore = 18, soundEnabled = true }, + ["neon-drop"] = { bestLines = 14, bestScore = 12600, soundEnabled = true }, + } + Bridge.Database.Query([[ + INSERT INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`) + VALUES (?, 'alarms', ?) + ON DUPLICATE KEY UPDATE `payload` = VALUES(`payload`), `revision` = `revision` + 1 + ]], { context.imei, json.encode(alarms) }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`) + VALUES (?, 'games', ?) + ON DUPLICATE KEY UPDATE `payload` = VALUES(`payload`), `revision` = `revision` + 1 + ]], { context.imei, json.encode(games) }) + + local contact_id = stable_uuid(context.key .. ":contact:alex") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_contacts` + (`id`, `contact_id`, `account_id`, `name`, `notes`, `organization`, `phone_number`, `favorite`) + VALUES (?, ?, ?, 'Alex Rivera', 'Test contact for calls and messages.', 'Downtown Cab Co.', ?, 1) + ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `notes` = VALUES(`notes`), + `organization` = VALUES(`organization`), `phone_number` = VALUES(`phone_number`), `favorite` = 1 + ]], { contact_id, contact_id, account_id, bot.sim.phone_number }) + + local sms_one = stable_uuid(context.key .. ":sms:one") + local sms_two = stable_uuid(context.key .. ":sms:two") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_sms_messages` + (`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`, `body`, `read_at`, `created_at`) + VALUES (?, ?, ?, ?, ?, 'Willkommen auf dem Testserver! Alle Apps sind jetzt befüllt.', NULL, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 8 MINUTE)) + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `read_at` = NULL + ]], { sms_one, bot.sim.id, context.sim.id, bot.sim.phone_number, context.sim.phone_number }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_sms_messages` + (`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`, `body`, `read_at`, `created_at`) + VALUES (?, ?, ?, ?, ?, 'Perfekt, ich teste gerade Nachrichten und Kontakte.', CURRENT_TIMESTAMP, + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)) + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `read_at` = CURRENT_TIMESTAMP + ]], { sms_two, context.sim.id, bot.sim.id, context.sim.phone_number, bot.sim.phone_number }) + + local call_id = stable_uuid(context.key .. ":call:missed") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_calls` + (`id`, `caller_sim_id`, `callee_sim_id`, `caller_number`, `callee_number`, `status`, + `started_at`, `ended_at`, `duration_seconds`) + VALUES (?, ?, ?, ?, ?, 'missed', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 2 HOUR), + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 2 HOUR), 0) + ON DUPLICATE KEY UPDATE `status` = 'missed', `duration_seconds` = 0 + ]], { call_id, bot.sim.id, context.sim.id, bot.sim.phone_number, context.sim.phone_number }) + Bridge.Database.Query("DELETE FROM `sky_phone_call_entries` WHERE `call_id` = ? AND `account_id` = ?", { call_id, account_id }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_call_entries` + (`call_id`, `account_id`, `direction`, `status`, `other_number`, `created_at`) + VALUES (?, ?, 'incoming', 'missed', ?, DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 2 HOUR)) + ]], { call_id, account_id, bot.sim.phone_number }) + + local note_one = stable_uuid(context.key .. ":note:checklist") + local note_two = stable_uuid(context.key .. ":note:ideas") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_notes` (`id`, `account_id`, `title`, `body`, `pinned`) + VALUES (?, ?, 'Phone Test-Checkliste', 'Kontakte\nNachrichten\nAnrufe\nSocial Apps\nMarktplatz\nFirmen', 1) + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `body` = VALUES(`body`), `pinned` = 1 + ]], { note_one, account_id }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_notes` (`id`, `account_id`, `title`, `body`, `pinned`) + VALUES (?, ?, 'Ideen für später', 'DarkChat testen und eine SkyRide-Fahrt bewerten.', 0) + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `body` = VALUES(`body`) + ]], { note_two, account_id }) + + local mail_id = stable_uuid(context.key .. ":mail:welcome") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_mail_messages` + (`id`, `sender_account_id`, `recipients`, `subject`, `body`, `created_at`) + VALUES (?, ?, ?, 'Willkommen beim iFruit-Test', + 'Hallo! Diese Nachricht gehört zu deinem reproduzierbaren Ingame-Testdatensatz.', + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 HOUR)) + ON DUPLICATE KEY UPDATE `recipients` = VALUES(`recipients`), `subject` = VALUES(`subject`), `body` = VALUES(`body`) + ]], { mail_id, bot.account.id, json.encode({ context.account.email }) }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_mail_entries` (`message_id`, `account_id`, `folder`) + VALUES (?, ?, 'inbox') + ]], { mail_id, account_id }) + local draft_id = stable_uuid(context.key .. ":mail:draft") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_mail_drafts` (`id`, `account_id`, `recipients`, `subject`, `body`) + VALUES (?, ?, ?, 'Entwurf: Testfeedback', 'Hier kann ich später mein Testfeedback ergänzen.') + ON DUPLICATE KEY UPDATE `recipients` = VALUES(`recipients`), `subject` = VALUES(`subject`), `body` = VALUES(`body`) + ]], { draft_id, account_id, json.encode({ bot.account.email }) }) + + Bridge.Database.Query( + "DELETE FROM `sky_phone_bank_transactions` WHERE `owner_identifier` = ? AND `reference` LIKE 'sky-phone-test:%'", + { context.identifier } + ) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_bank_transactions` (`owner_identifier`, `kind`, `amount`, `label`, `reference`, `created_at`) + VALUES (?, 'deposit', 2500, 'Test paycheck', 'sky-phone-test:paycheck', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY)), + (?, 'withdrawal', 85, 'Los Santos Customs', 'sky-phone-test:repair', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 3 HOUR)), + (?, 'transfer_in', 420, 'Alex Rivera', 'sky-phone-test:transfer', DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 30 MINUTE)) + ]], { context.identifier, context.identifier, context.identifier }) + + local invoice_id = stable_uuid(context.key .. ":invoice:repair") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_billing_invoices` + (`id`, `recipient_identifier`, `issuer_identifier`, `issuer_account`, `issuer_label`, + `title`, `description`, `amount`, `currency`, `status`, `due_at`) + VALUES (?, ?, 'sky_phone:testbot:mechanic', 'society_mechanic', 'Los Santos Customs', + 'Vehicle inspection', 'Test invoice for the Billing app.', 750, '$', 'open', + DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 3 DAY)) + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `description` = VALUES(`description`), + `amount` = VALUES(`amount`), `status` = 'open', `read_at` = NULL + ]], { invoice_id, context.identifier }) + Bridge.Database.Query( + "DELETE FROM `sky_phone_billing_events` WHERE `invoice_id` = ? AND `note` = 'Generated by the phone test data command.'", + { invoice_id } + ) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_billing_events` (`invoice_id`, `event`, `actor_identifier`, `note`) + VALUES (?, 'created', 'sky_phone:testbot:mechanic', 'Generated by the phone test data command.') + ]], { invoice_id }) + + local event_id = stable_uuid(context.key .. ":calendar:meeting") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_calendar_events` + (`id`, `account_id`, `title`, `note`, `starts_at`, `ends_at`, `reminder_minutes`) + VALUES (?, ?, 'Phone QA Session', 'Alle Apps im Spiel durchtesten.', + DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 1 DAY), DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 25 HOUR), 30) + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `note` = VALUES(`note`), + `starts_at` = VALUES(`starts_at`), `ends_at` = VALUES(`ends_at`), `reminder_minutes` = 30, + `reminded_at` = NULL + ]], { event_id, account_id }) + + local marker_one = stable_uuid(context.key .. ":marker:lsc") + local marker_two = stable_uuid(context.key .. ":marker:pier") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_map_markers` (`id`, `device_imei`, `label`, `color`, `position_x`, `position_y`, `position_z`) + VALUES (?, ?, 'Los Santos Customs', 'orange', -337.3, -136.9, 39.0) + ON DUPLICATE KEY UPDATE `label` = VALUES(`label`), `color` = VALUES(`color`), + `position_x` = VALUES(`position_x`), `position_y` = VALUES(`position_y`), `position_z` = VALUES(`position_z`) + ]], { marker_one, context.imei }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_map_markers` (`id`, `device_imei`, `label`, `color`, `position_x`, `position_y`, `position_z`) + VALUES (?, ?, 'Del Perro Pier', 'blue', -1649.7, -1078.3, 13.0) + ON DUPLICATE KEY UPDATE `label` = VALUES(`label`), `color` = VALUES(`color`), + `position_x` = VALUES(`position_x`), `position_y` = VALUES(`position_y`), `position_z` = VALUES(`position_z`) + ]], { marker_two, context.imei }) + + local song_id = stable_uuid(context.key .. ":music:song") + local playlist_id = stable_uuid(context.key .. ":music:playlist") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_music_youtube_songs` (`id`, `account_id`, `video_id`, `title`, `artist`) + VALUES (?, ?, 'dQw4w9WgXcQ', 'Never Gonna Give You Up', 'Rick Astley') + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `artist` = VALUES(`artist`) + ]], { song_id, account_id }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_music_playlists` (`id`, `account_id`, `name`) + VALUES (?, ?, 'Test Drive Mix') + ON DUPLICATE KEY UPDATE `name` = VALUES(`name`) + ]], { playlist_id, account_id }) + Bridge.Database.Query("DELETE FROM `sky_phone_music_playlist_items` WHERE `playlist_id` = ?", { playlist_id }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_music_playlist_items` (`playlist_id`, `source`, `song_id`, `position`) + VALUES (?, 'youtube', ?, 1) + ]], { playlist_id, song_id }) + + Bridge.Database.Query([[ + INSERT INTO `sky_phone_radio_profiles` + (`identifier`, `history`, `settings`, `primary_frequency`, `secondary_frequency`, `badge`, `display_name`) + VALUES (?, ?, ?, 100.1, 101.5, 'QA', ?) + ON DUPLICATE KEY UPDATE `history` = VALUES(`history`), `settings` = VALUES(`settings`), + `primary_frequency` = VALUES(`primary_frequency`), `secondary_frequency` = VALUES(`secondary_frequency`) + ]], { + context.identifier, + json.encode({ 100.1, 101.5, 99.9 }), + json.encode({ volume = 65, notifications = true, autoRejoin = false }), + context.player_name, + }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_easyshare_preferences` (`device_imei`, `visibility`) + VALUES (?, 'everyone') ON DUPLICATE KEY UPDATE `visibility` = 'everyone' + ]], { context.imei }) + local transfer_id = stable_uuid(context.key .. ":easyshare:transfer") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_easyshare_transfers` + (`id`, `sender_imei`, `recipient_imei`, `sender_name`, `recipient_name`, `content_type`, + `payload`, `status`, `progress`, `completed_at`) + VALUES (?, ?, ?, 'Alex Rivera', ?, 'contact', ?, 'completed', 100, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE `payload` = VALUES(`payload`), `status` = 'completed', + `progress` = 100, `completed_at` = CURRENT_TIMESTAMP + ]], { + transfer_id, + bot.imei, + context.imei, + context.player_name, + json.encode({ name = "Mia Chen", phoneNumber = context.bot_two.sim.phone_number }), + }) +end + +local function seed_marketplace_and_pages(context) + local account_id = context.account.id + local bot_id = context.bot_one.account.id + local own_handle = "tester" .. tostring(account_id) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_marketplace_profiles` (`account_id`, `display_name`, `bio`, `avatar_media_id`) + VALUES (?, ?, 'QA profile generated in game.', ?) + ON DUPLICATE KEY UPDATE `display_name` = VALUES(`display_name`), `bio` = VALUES(`bio`), + `avatar_media_id` = VALUES(`avatar_media_id`) + ]], { account_id, context.player_name, context.media.user_portrait }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_marketplace_profiles` (`account_id`, `display_name`, `bio`, `avatar_media_id`) + VALUES (?, 'Alex Rivera', 'Trusted test seller.', ?) + ON DUPLICATE KEY UPDATE `display_name` = VALUES(`display_name`), `bio` = VALUES(`bio`), + `avatar_media_id` = VALUES(`avatar_media_id`) + ]], { bot_id, context.media.bot_portrait }) + + local bot_listing = stable_uuid(context.key .. ":market:bot-listing") + local own_listing = stable_uuid(context.key .. ":market:own-listing") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_marketplace_listings` + (`id`, `seller_account_id`, `title`, `description`, `category`, `item_condition`, + `price_type`, `price`, `district`, `show_phone`, `phone_number`, `status`, `expires_at`) + VALUES (?, ?, 'Sultan RS - Test Vehicle', 'Clean test listing with negotiable price.', + 'vehicles', 'very_good', 'negotiable', 42000, 'los_santos', 1, ?, 'active', DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 30 DAY)) + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `description` = VALUES(`description`), + `status` = 'active', `expires_at` = VALUES(`expires_at`) + ]], { bot_listing, bot_id, context.bot_one.sim.phone_number }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_marketplace_listings` + (`id`, `seller_account_id`, `title`, `description`, `category`, `item_condition`, + `price_type`, `price`, `district`, `status`, `expires_at`) + VALUES (?, ?, 'QA Headset', 'A listing owned by the test user.', 'electronics', 'used', + 'fixed', 350, 'los_santos', 'active', DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 30 DAY)) + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `description` = VALUES(`description`), + `status` = 'active', `expires_at` = VALUES(`expires_at`) + ]], { own_listing, account_id }) + local car_gradient = ("url(%s)"):format(json.encode(photo_urls.car)) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_marketplace_images` (`listing_id`, `media_id`, `gradient`, `sort_order`) + VALUES (?, ?, ?, 1) + ON DUPLICATE KEY UPDATE `media_id` = VALUES(`media_id`), `gradient` = VALUES(`gradient`) + ]], { bot_listing, tostring(context.media.bot_car), car_gradient }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_marketplace_favorites` (`account_id`, `listing_id`) VALUES (?, ?) + ]], { account_id, bot_listing }) + local inquiry_id = stable_uuid(context.key .. ":market:inquiry") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_marketplace_inquiries` + (`id`, `listing_id`, `seller_account_id`, `buyer_account_id`, `offer_amount`, + `offer_proposer_account_id`, `offer_status`, `offer_revision`) + VALUES (?, ?, ?, ?, 40000, ?, 'pending', 1) + ON DUPLICATE KEY UPDATE `offer_amount` = 40000, `offer_proposer_account_id` = VALUES(`offer_proposer_account_id`), + `offer_status` = 'pending', `offer_revision` = 1 + ]], { inquiry_id, bot_listing, bot_id, account_id, account_id }) + Bridge.Database.Query("DELETE FROM `sky_phone_marketplace_messages` WHERE `inquiry_id` = ?", { inquiry_id }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_marketplace_messages` (`inquiry_id`, `sender_account_id`, `body`, `read_at`) + VALUES (?, ?, 'Ist der Sultan noch verfügbar?', CURRENT_TIMESTAMP), + (?, ?, 'Ja, gerne Probefahrt in Burton.', NULL) + ]], { inquiry_id, account_id, inquiry_id, bot_id }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_marketplace_offers` (`inquiry_id`, `proposer_account_id`, `amount`) + VALUES (?, ?, 40000) + ]], { inquiry_id, account_id }) + + Bridge.Database.Query([[ + INSERT INTO `sky_phone_pages_profiles` (`account_id`, `handle`, `bio`, `avatar_media_id`) + VALUES (?, ?, 'Lokale Tests, Events und Angebote.', ?) + ON DUPLICATE KEY UPDATE `handle` = VALUES(`handle`), `bio` = VALUES(`bio`), + `avatar_media_id` = VALUES(`avatar_media_id`) + ]], { account_id, own_handle, context.media.user_portrait }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_pages_profiles` (`account_id`, `handle`, `bio`, `avatar_media_id`) + VALUES (?, 'alex.local', 'News aus Los Santos.', ?) + ON DUPLICATE KEY UPDATE `bio` = VALUES(`bio`), `avatar_media_id` = VALUES(`avatar_media_id`) + ]], { bot_id, context.media.bot_portrait }) + local page_post = stable_uuid(context.key .. ":pages:post") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_pages_posts` + (`id`, `account_id`, `source_type`, `title`, `body`, `category`, `district`) + VALUES (?, ?, 'personal', 'Car Meet am Pier', 'Heute Abend findet ein offenes Test-Car-Meet statt.', + 'event', 'los_santos') + ON DUPLICATE KEY UPDATE `title` = VALUES(`title`), `body` = VALUES(`body`) + ]], { page_post, bot_id }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_pages_images` (`post_id`, `media_id`, `gradient`, `sort_order`) + VALUES (?, ?, ?, 1) + ON DUPLICATE KEY UPDATE `media_id` = VALUES(`media_id`), `gradient` = VALUES(`gradient`) + ]], { page_post, tostring(context.media.bot_car), car_gradient }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_pages_reactions` (`post_id`, `account_id`, `kind`) + VALUES (?, ?, 'like'), (?, ?, 'save') + ]], { page_post, account_id, page_post, account_id }) +end + +local function seed_social_apps(context) + local account_id = context.account.id + local bot_id = context.bot_one.account.id + local bot_two_id = context.bot_two.account.id + local suffix = tostring(account_id) + + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_profiles` + (`id`, `account_id`, `handle`, `display_name`, `bio`, `avatar_media_id`) + VALUES (?, ?, ?, ?, 'Ingame QA account', ?) + ON DUPLICATE KEY UPDATE `handle` = VALUES(`handle`), `display_name` = VALUES(`display_name`), + `bio` = VALUES(`bio`), `avatar_media_id` = VALUES(`avatar_media_id`) + ]], { + stable_uuid(context.key .. ":pic:user"), account_id, "tester" .. suffix, context.player_name, + context.media.user_portrait, + }) + local pic_user = ensure_string_profile("sky_phone_picstagram_profiles", account_id) + local pic_bot_seed = stable_uuid("sky_phone:testbot:pic:alex") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_profiles` + (`id`, `account_id`, `handle`, `display_name`, `bio`, `avatar_media_id`, `verified`) + VALUES (?, ?, 'alex.rivera', 'Alex Rivera', 'Cars, city lights and test content.', ?, 1) + ON DUPLICATE KEY UPDATE `display_name` = VALUES(`display_name`), `bio` = VALUES(`bio`), + `avatar_media_id` = VALUES(`avatar_media_id`), `verified` = 1 + ]], { pic_bot_seed, bot_id, context.media.bot_portrait }) + local pic_bot = ensure_string_profile("sky_phone_picstagram_profiles", bot_id) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_sessions` (`device_imei`, `profile_id`) + VALUES (?, ?) ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`) + ]], { context.imei, pic_user }) + local pic_post = stable_uuid(context.key .. ":pic:post") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_posts` (`id`, `profile_id`, `caption`, `location`) + VALUES (?, ?, 'Night drive through Los Santos #test', 'Vinewood') + ON DUPLICATE KEY UPDATE `caption` = VALUES(`caption`), `location` = VALUES(`location`), `status` = 'published' + ]], { pic_post, pic_bot }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_post_media` (`post_id`, `media_id`, `position`) + VALUES (?, ?, 1) ON DUPLICATE KEY UPDATE `media_id` = VALUES(`media_id`) + ]], { pic_post, context.media.bot_city }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_picstagram_follows` (`follower_id`, `following_id`, `status`) + VALUES (?, ?, 'accepted') + ]], { pic_user, pic_bot }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_picstagram_reactions` (`post_id`, `profile_id`, `kind`) + VALUES (?, ?, 'like'), (?, ?, 'save') + ]], { pic_post, pic_user, pic_post, pic_user }) + local pic_comment = stable_uuid(context.key .. ":pic:comment") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_comments` (`id`, `post_id`, `profile_id`, `body`) + VALUES (?, ?, ?, 'Sieht richtig gut aus!') + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `status` = 'visible' + ]], { pic_comment, pic_post, pic_user }) + local story_id = stable_uuid(context.key .. ":pic:story") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_stories` (`id`, `profile_id`, `media_id`, `body`, `expires_at`) + VALUES (?, ?, ?, 'Test story live', DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)) + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `status` = 'active', `expires_at` = VALUES(`expires_at`) + ]], { story_id, pic_bot, context.media.bot_city }) + local pic_activity = stable_uuid(context.key .. ":pic:activity") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_activities` (`id`, `recipient_id`, `actor_id`, `post_id`, `kind`) + VALUES (?, ?, ?, ?, 'like') + ON DUPLICATE KEY UPDATE `read_at` = NULL + ]], { pic_activity, pic_user, pic_bot, pic_post }) + + Bridge.Database.Query([[ + INSERT INTO `sky_phone_fliptok_profiles` (`account_id`, `handle`, `display_name`, `bio`) + VALUES (?, ?, ?, 'Testing every FlipTok feature.') + ON DUPLICATE KEY UPDATE `handle` = VALUES(`handle`), `display_name` = VALUES(`display_name`), `bio` = VALUES(`bio`) + ]], { account_id, "tester" .. suffix, context.player_name }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_fliptok_profiles` + (`account_id`, `handle`, `display_name`, `bio`, `account_type`, `verified`) + VALUES (?, 'mia.motion', 'Mia Chen', 'Short videos from Los Santos.', 'media', 1) + ON DUPLICATE KEY UPDATE `display_name` = VALUES(`display_name`), `bio` = VALUES(`bio`), `verified` = 1 + ]], { bot_two_id }) + local flip_user = ensure_numeric_profile("sky_phone_fliptok_profiles", account_id) + local flip_bot = ensure_numeric_profile("sky_phone_fliptok_profiles", bot_two_id) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_fliptok_sessions` (`device_imei`, `profile_id`) + VALUES (?, ?) ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`) + ]], { context.imei, flip_user }) + local flip_video = stable_uuid(context.key .. ":flip:video") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_fliptok_videos` + (`id`, `profile_id`, `media_id`, `caption`, `location`, `view_count`, `share_count`) + VALUES (?, ?, ?, 'Flowers in motion #fyp #test', 'Mirror Park', 1842, 37) + ON DUPLICATE KEY UPDATE `caption` = VALUES(`caption`), `view_count` = 1842, + `share_count` = 37, `status` = 'published' + ]], { flip_video, flip_bot, context.media.bot_video }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_fliptok_follows` (`follower_id`, `following_id`) VALUES (?, ?) + ]], { flip_user, flip_bot }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_fliptok_reactions` (`video_id`, `profile_id`, `kind`) + VALUES (?, ?, 'like'), (?, ?, 'save') + ]], { flip_video, flip_user, flip_video, flip_user }) + local flip_comment = stable_uuid(context.key .. ":flip:comment") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_fliptok_comments` (`id`, `video_id`, `profile_id`, `body`) + VALUES (?, ?, ?, 'Der Test-Clip läuft flüssig.') + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `status` = 'visible' + ]], { flip_comment, flip_video, flip_user }) + local flip_notification = stable_uuid(context.key .. ":flip:notification") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_fliptok_notifications` (`id`, `recipient_id`, `actor_id`, `video_id`, `kind`) + VALUES (?, ?, ?, ?, 'follow') ON DUPLICATE KEY UPDATE `read_at` = NULL + ]], { flip_notification, flip_user, flip_bot, flip_video }) + + Bridge.Database.Query([[ + INSERT INTO `sky_phone_feather_profiles` (`account_id`, `handle`, `display_name`, `bio`, `avatar_media_id`) + VALUES (?, ?, ?, 'Testing Feather in game.', ?) + ON DUPLICATE KEY UPDATE `handle` = VALUES(`handle`), `display_name` = VALUES(`display_name`), + `bio` = VALUES(`bio`), `avatar_media_id` = VALUES(`avatar_media_id`) + ]], { account_id, "tester" .. suffix, context.player_name, context.media.user_portrait }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_feather_profiles` + (`account_id`, `handle`, `display_name`, `bio`, `avatar_media_id`, `verified`) + VALUES (?, 'alex_updates', 'Alex Rivera', 'Los Santos live updates.', ?, 1) + ON DUPLICATE KEY UPDATE `display_name` = VALUES(`display_name`), `bio` = VALUES(`bio`), + `avatar_media_id` = VALUES(`avatar_media_id`), `verified` = 1 + ]], { bot_id, context.media.bot_portrait }) + local feather_user = ensure_numeric_profile("sky_phone_feather_profiles", account_id) + local feather_bot = ensure_numeric_profile("sky_phone_feather_profiles", bot_id) + local feather_post = stable_uuid(context.key .. ":feather:post") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_feather_posts` (`id`, `profile_id`, `body`) + VALUES (?, ?, 'Der neue iFruit-Testdatensatz ist live. #LosSantos #PhoneQA') + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `status` = 'published' + ]], { feather_post, feather_bot }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_feather_hashtags` (`post_id`, `tag`) + VALUES (?, 'lossantos'), (?, 'phoneqa') + ]], { feather_post, feather_post }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_feather_follows` (`follower_id`, `following_id`) VALUES (?, ?) + ]], { feather_user, feather_bot }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_feather_reactions` (`post_id`, `profile_id`, `kind`) + VALUES (?, ?, 'like'), (?, ?, 'bookmark') + ]], { feather_post, feather_user, feather_post, feather_user }) + local feather_notification = stable_uuid(context.key .. ":feather:notification") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_feather_notifications` (`id`, `recipient_id`, `actor_id`, `post_id`, `kind`) + VALUES (?, ?, ?, ?, 'follow') ON DUPLICATE KEY UPDATE `read_at` = NULL + ]], { feather_notification, feather_user, feather_bot, feather_post }) + + Bridge.Database.Query([[ + INSERT INTO `sky_phone_flare_profiles` + (`account_id`, `name`, `age`, `bio`, `gender`, `interested_in`, `min_age`, `max_age`, + `avatar`, `interests`, `looking_for`) + VALUES (?, ?, 27, 'Testing Flare conversations.', 'nonbinary', 'everyone', 21, 40, 0, ?, 'friends') + ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `bio` = VALUES(`bio`), `interests` = VALUES(`interests`) + ]], { account_id, context.player_name:sub(1, 32), json.encode({ "cars", "music", "gaming" }) }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_flare_profiles` + (`account_id`, `name`, `age`, `bio`, `gender`, `interested_in`, `min_age`, `max_age`, + `avatar`, `interests`, `looking_for`) + VALUES (?, 'Mia', 26, 'Coffee, sunsets and good conversations.', 'woman', 'everyone', 21, 40, 1, ?, 'friends') + ON DUPLICATE KEY UPDATE `bio` = VALUES(`bio`), `interests` = VALUES(`interests`) + ]], { bot_two_id, json.encode({ "coffee", "travel", "photography" }) }) + local flare_user = ensure_numeric_profile("sky_phone_flare_profiles", account_id) + local flare_bot = ensure_numeric_profile("sky_phone_flare_profiles", bot_two_id) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_flare_profile_photos` (`profile_id`, `media_id`, `sort_order`) + VALUES (?, ?, 1), (?, ?, 1) + ]], { flare_user, context.media.user_portrait, flare_bot, context.media.bot_two_portrait }) + local match_id = stable_uuid(context.key .. ":flare:match") + local account_a = math.min(account_id, bot_two_id) + local account_b = math.max(account_id, bot_two_id) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_flare_matches` (`id`, `account_a_id`, `account_b_id`) + VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `created_at` = `created_at` + ]], { match_id, account_a, account_b }) + local flare_message = stable_uuid(context.key .. ":flare:message") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_flare_messages` (`id`, `match_id`, `sender_account_id`, `body`, `read_at`) + VALUES (?, ?, ?, 'Hey! Bereit für einen vollständigen App-Test?', NULL) + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `read_at` = NULL + ]], { flare_message, match_id, bot_two_id }) +end + +local function seed_private_and_services(context) + local account_id = context.account.id + local bot_id = context.bot_one.account.id + Bridge.Database.Query([[ + INSERT INTO `sky_phone_darkchat_profiles` + (`account_id`, `dark_id`, `invite_code`, `alias`, `avatar_seed`, `notification_mode`, `activity_visible`) + VALUES (?, ?, ?, 'NightTester', 42, 'private', 1) + ON DUPLICATE KEY UPDATE `alias` = VALUES(`alias`), `notification_mode` = VALUES(`notification_mode`) + ]], { account_id, ("DARK%010d"):format(account_id % 10000000000), ("INV%08d"):format(account_id % 100000000) }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_darkchat_profiles` + (`account_id`, `dark_id`, `invite_code`, `alias`, `avatar_seed`, `notification_mode`, `activity_visible`) + VALUES (?, 'DARK0000000001', 'INV00000001', 'GhostAlex', 17, 'full', 1) + ON DUPLICATE KEY UPDATE `alias` = VALUES(`alias`) + ]], { bot_id }) + local dark_user = ensure_numeric_profile("sky_phone_darkchat_profiles", account_id) + local dark_bot = ensure_numeric_profile("sky_phone_darkchat_profiles", bot_id) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_darkchat_contacts` (`profile_id`, `contact_profile_id`, `alias_override`) + VALUES (?, ?, 'Ghost') + ]], { dark_user, dark_bot }) + local conversation_id = stable_uuid(context.key .. ":dark:conversation") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_darkchat_conversations` (`id`, `disappearing_seconds`) + VALUES (?, 0) ON DUPLICATE KEY UPDATE `disappearing_seconds` = 0 + ]], { conversation_id }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_darkchat_members` (`conversation_id`, `profile_id`, `last_read_at`) + VALUES (?, ?, CURRENT_TIMESTAMP), (?, ?, NULL) + ]], { conversation_id, dark_user, conversation_id, dark_bot }) + local dark_one = stable_uuid(context.key .. ":dark:message:one") + local dark_two = stable_uuid(context.key .. ":dark:message:two") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_darkchat_messages` (`id`, `conversation_id`, `sender_profile_id`, `body`) + VALUES (?, ?, ?, 'Willkommen im privaten Testchat.') + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `deleted_for_everyone` = 0 + ]], { dark_one, conversation_id, dark_bot }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_darkchat_messages` + (`id`, `conversation_id`, `sender_profile_id`, `message_type`, `body`, `reactions`) + VALUES (?, ?, ?, 'emoji', '🔥', ?) + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `reactions` = VALUES(`reactions`) + ]], { dark_two, conversation_id, dark_user, json.encode({ ["🔥"] = { dark_bot } }) }) + + local crew_user_seed = stable_uuid(context.key .. ":crew:user") + local crew_bot_seed = stable_uuid("sky_phone:testbot:crew:alex") + local group_id = stable_uuid(context.key .. ":crew:group") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crewlink_profiles` (`id`, `account_id`, `username`, `active_group_id`) + VALUES (?, ?, ?, NULL) + ON DUPLICATE KEY UPDATE `username` = VALUES(`username`) + ]], { crew_user_seed, account_id, ("tester%s"):format(account_id):sub(1, 20) }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crewlink_profiles` (`id`, `account_id`, `username`, `active_group_id`) + VALUES (?, ?, 'alexcrew', NULL) + ON DUPLICATE KEY UPDATE `username` = VALUES(`username`) + ]], { crew_bot_seed, bot_id }) + local crew_user = ensure_string_profile("sky_phone_crewlink_profiles", account_id) + local crew_bot = ensure_string_profile("sky_phone_crewlink_profiles", bot_id) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crewlink_groups` + (`id`, `name`, `colour`, `owner_profile_id`, `invite_code`, `allow_member_pings`, `overhead_allowed`) + VALUES (?, 'Phone QA Crew', 'violet', ?, ?, 1, 1) + ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `colour` = VALUES(`colour`) + ]], { group_id, crew_user, ("QA%s"):format(account_id):sub(1, 12) }) + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_crewlink_memberships` (`group_id`, `profile_id`, `role`) + VALUES (?, ?, 'owner'), (?, ?, 'member') + ]], { group_id, crew_user, group_id, crew_bot }) + Bridge.Database.Query( + "UPDATE `sky_phone_crewlink_profiles` SET `active_group_id` = ? WHERE `id` IN (?, ?)", + { group_id, crew_user, crew_bot } + ) + local ping_id = stable_uuid(context.key .. ":crew:ping") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_crewlink_pings` + (`id`, `group_id`, `creator_profile_id`, `type`, `label`, `position_x`, `position_y`, `position_z`, `expires_at`) + VALUES (?, ?, ?, 'meeting', 'QA Treffpunkt', -337.3, -136.9, 39.0, + DATE_ADD(CURRENT_TIMESTAMP, INTERVAL 12 HOUR)) + ON DUPLICATE KEY UPDATE `label` = VALUES(`label`), `expires_at` = VALUES(`expires_at`) + ]], { ping_id, group_id, crew_bot }) + + local ride_user_seed = stable_uuid(context.key .. ":skyride:user") + local ride_bot_seed = stable_uuid("sky_phone:testbot:skyride:alex") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_skyride_profiles` (`id`, `owner_identifier`) + VALUES (?, ?) ON DUPLICATE KEY UPDATE `owner_identifier` = VALUES(`owner_identifier`) + ]], { ride_user_seed, context.identifier }) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_skyride_profiles` (`id`, `owner_identifier`) + VALUES (?, 'sky_phone:testbot:alex') ON DUPLICATE KEY UPDATE `owner_identifier` = VALUES(`owner_identifier`) + ]], { ride_bot_seed }) + local ride_user_rows = Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_skyride_profiles` WHERE `owner_identifier` = ? LIMIT 1", + { context.identifier } + ) + local ride_bot_rows = Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_skyride_profiles` WHERE `owner_identifier` = 'sky_phone:testbot:alex' LIMIT 1", + {} + ) + local ride_user = ride_user_rows[1] and ride_user_rows[1].id + local ride_bot = ride_bot_rows[1] and ride_bot_rows[1].id + if type(ride_user) ~= "string" or type(ride_bot) ~= "string" then + error("[sky_phone] Test SkyRide profiles could not be loaded.") + end + local ride_id = stable_uuid(context.key .. ":skyride:completed") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_skyride_rides` + (`id`, `passenger_profile_id`, `driver_profile_id`, `passenger_name`, `driver_name`, + `status`, `service_class`, `pickup_label`, `pickup_x`, `pickup_y`, `pickup_z`, + `destination_label`, `destination_x`, `destination_y`, `destination_z`, `distance_meters`, + `duration_seconds`, `price`, `payout_amount`, `currency`, `driver_vehicle_model`, + `driver_vehicle_color`, `driver_vehicle_plate`, `passenger_rating`, `rating_comment`, + `tip_amount`, `tip_status`, `accepted_at`, `arrived_at`, `started_at`, `completed_at`, `paid_out_at`) + VALUES (?, ?, ?, ?, 'Alex Rivera', 'completed', 'comfort', 'Legion Square', 215.8, -810.1, 30.7, + 'Del Perro Pier', -1649.7, -1078.3, 13.0, 6200, 540, 320, 240, '$', 'Sultan', + 'Midnight Blue', 'QA 2026', 5, 'Saubere Testfahrt.', 25, 'completed', + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 3 HOUR), DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 170 MINUTE), + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 165 MINUTE), DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 2 HOUR), + DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 2 HOUR)) + ON DUPLICATE KEY UPDATE `status` = 'completed', `passenger_rating` = 5, + `rating_comment` = 'Saubere Testfahrt.', `tip_amount` = 25, `tip_status` = 'completed' + ]], { ride_id, ride_user, ride_bot, context.player_name }) + + local company_rows = Bridge.Database.Query( + "SELECT `company_id` FROM `sky_phone_company_profiles` WHERE `company_id` = 'mechanic' LIMIT 1", + {} + ) + if company_rows[1] then + local request_id = stable_uuid(context.key .. ":company:request") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_company_requests` + (`id`, `company_id`, `service_id`, `customer_sim_id`, `subject`, `description`, + `status`, `customer_unread`, `company_activity_revision`, `revision`) + VALUES (?, 'mechanic', 'mechanic-repair', ?, 'Motor verliert Leistung', + 'Testauftrag mit Chatverlauf und Status.', 'waiting_customer', 1, 3, 3) + ON DUPLICATE KEY UPDATE `subject` = VALUES(`subject`), `description` = VALUES(`description`), + `status` = 'waiting_customer', `customer_unread` = 1, `company_activity_revision` = 3, `revision` = 3 + ]], { request_id, context.sim.id }) + local request_message = stable_uuid(context.key .. ":company:message") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_company_request_messages` + (`id`, `request_id`, `sender_type`, `sender_identifier`, `body`) + VALUES (?, ?, 'company', 'sky_phone:testbot:mechanic', + 'Bitte komm für eine Diagnose bei Los Santos Customs vorbei.') + ON DUPLICATE KEY UPDATE `body` = VALUES(`body`) + ]], { request_message, request_id }) + local request_event = stable_uuid(context.key .. ":company:event") + Bridge.Database.Query([[ + INSERT INTO `sky_phone_company_request_events` + (`id`, `request_id`, `event_type`, `actor_type`, `actor_identifier`, `from_status`, `to_status`, `detail`) + VALUES (?, ?, 'status', 'company', 'sky_phone:testbot:mechanic', 'in_progress', 'waiting_customer', + 'Waiting for the test customer.') + ON DUPLICATE KEY UPDATE `detail` = VALUES(`detail`) + ]], { request_event, request_id }) + end +end + +local function seed_for_source(source) + local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item) + local phone_slot = slots[1] + if not phone_slot then + error(("[sky_phone] Test data source %s has no phone item."):format(source)) + end + local imei, device_error = SkyPhone.EnsureDevice(source, phone_slot) + if not imei then + error(("[sky_phone] Test data could not resolve the phone device: %s"):format(tostring(device_error))) + end + local identifier = Bridge.Framework.GetIdentifier(source) + if type(identifier) ~= "string" or identifier == "" then + error("[sky_phone] Test data could not resolve the player identifier.") + end + + local player_name = ((Bridge.Framework.GetFirstname(source) or "") .. " " + .. (Bridge.Framework.GetLastname(source) or "")):match("^%s*(.-)%s*$") + if player_name == "" then + player_name = GetPlayerName(source) or ("Player %s"):format(source) + end + local device = SkyPhone.LoadDevice(imei) + local account + if device and device.account_id then + local rows = Bridge.Database.Query( + "SELECT `id`, `email` FROM `sky_phone_accounts` WHERE `id` = ? LIMIT 1", + { device.account_id } + ) + account = rows[1] + if not account then + error("[sky_phone] Test data found a device with a missing iFruit account.") + end + account.id = tonumber(account.id) + else + account = ensure_account("tester_" .. seed_hash(identifier) .. "@" .. Config.Mail.Domain) + Bridge.Database.Query("UPDATE `sky_phone_devices` SET `account_id` = ? WHERE `imei` = ?", { account.id, imei }) + end + + device = SkyPhone.LoadDevice(imei) + local sim + if device and device.sim_id then + local rows = Bridge.Database.Query("SELECT * FROM `sky_phone_sims` WHERE `id` = ? LIMIT 1", { device.sim_id }) + sim = rows[1] + else + sim = reserve_sim(identifier, Bridge.Framework.GetFirstname(source), Bridge.Framework.GetLastname(source)) + Bridge.Database.Query("UPDATE `sky_phone_devices` SET `sim_id` = ? WHERE `imei` = ?", { sim.id, imei }) + local metadata = phone_slot.metadata or {} + metadata.sim_id = sim.id + metadata.phone_number = sim.phone_number + metadata.formatted_number = SkyPhoneSimNumber.Format( + sim.phone_number, + Config.Sim.NumberGroups, + Config.Sim.NumberLength, + Config.Sim.NumberPrefix + ) + if not Bridge.Inventory.SetSlotMetadata(source, phone_slot.slot, metadata) then + error("[sky_phone] Test data could not update the phone item's SIM metadata.") + end + end + + local bot_one = ensure_bot("alex", "phone.bot.alex", "990000000000001", "Alex", "Rivera") + local bot_two = ensure_bot("mia", "phone.bot.mia", "990000000000002", "Mia", "Chen") + local context = { + source = source, + identifier = identifier, + player_name = player_name, + account = account, + imei = imei, + sim = sim, + bot_one = bot_one, + bot_two = bot_two, + key = identifier .. ":" .. imei, + media = {}, + } + context.media.user_portrait = ensure_media(account.id, "test-user-portrait", photo_urls.portrait, "photo") + context.media.user_city = ensure_media(account.id, "test-user-city", photo_urls.city, "photo") + context.media.user_video = ensure_media(account.id, "test-user-video", video_url, "video") + context.media.bot_portrait = ensure_media(bot_one.account.id, "test-bot-alex-portrait", photo_urls.car, "photo") + context.media.bot_car = ensure_media(bot_one.account.id, "test-bot-alex-car", photo_urls.car, "photo") + context.media.bot_city = ensure_media(bot_one.account.id, "test-bot-alex-city", photo_urls.city, "photo") + context.media.bot_two_portrait = ensure_media(bot_two.account.id, "test-bot-mia-portrait", photo_urls.portrait, "photo") + context.media.bot_video = ensure_media(bot_two.account.id, "test-bot-mia-video", video_url, "video") + + seed_core(context) + seed_marketplace_and_pages(context) + seed_social_apps(context) + seed_private_and_services(context) + SkyPhone.RefreshSource(source) + return account.email +end + +RegisterCommand(Config.TestData.Command, function(source) + if source <= 0 then + Bridge.Debug("warn", "[sky_phone] The test data command must be run by an in-game player.") + return + end + if Config.TestData.AdminOnly and not Bridge.Framework.HasAdminGroup(source, Config.TestData.AdminGroups) then + Bridge.Debug("warn", "[sky_phone] Source %s attempted to run the restricted test data command.", tostring(source)) + TriggerClientEvent("sky_phone:testdata:feedback", source, false) + return + end + local now = os.time() + if seed_attempts[source] and now - seed_attempts[source] < 30 then + Bridge.Debug("warn", "[sky_phone] Source %s repeated the test data command too quickly.", tostring(source)) + TriggerClientEvent("sky_phone:testdata:feedback", source, false) + return + end + seed_attempts[source] = now + local success, result = pcall(seed_for_source, source) + if not success then + Bridge.Debug("error", "[sky_phone] Test data seeding failed for source %s: %s", tostring(source), tostring(result)) + TriggerClientEvent("sky_phone:testdata:feedback", source, false) + return + end + Bridge.Debug("info", "[sky_phone] Test data seeded for source %s.", tostring(source), { always = true }) + TriggerClientEvent("sky_phone:testdata:feedback", source, true, result) +end, false) + +AddEventHandler("playerDropped", function() + seed_attempts[source] = nil +end) + +end) diff --git a/tests/client_focus.lua b/tests/client_focus.lua new file mode 100644 index 0000000..f70ae6c --- /dev/null +++ b/tests/client_focus.lua @@ -0,0 +1,67 @@ +dofile("sky_phone/source/client/focus.lua") + +local function resolve(overrides) + local state = { + activity_suspended = false, + call_focus = false, + camera_active = false, + camera_nui_focused = true, + is_open = false, + notification_focus = false, + payphone_focus = false, + sim_picker_open = false, + } + for key, value in pairs(overrides or {}) do + state[key] = value + end + return SkyPhoneFocus.Resolve(state) +end + +local idle = resolve() +assert(not idle.focused and not idle.keep_input, "idle NUI must release focus and game input override") + +local minimized_call = resolve() +assert(not minimized_call.focused, "a replayed call without an attention claim must stay unfocused") + +local incoming_call = resolve({ call_focus = true }) +assert(incoming_call.focused and not incoming_call.keep_input, "incoming call attention must focus the NUI") + +local camera_game_input = resolve({ + camera_active = true, + camera_nui_focused = false, + is_open = true, +}) +assert( + not camera_game_input.focused and camera_game_input.keep_input, + "unfocused camera must own game input over the open phone" +) + +local camera_interrupted_by_call = resolve({ + call_focus = true, + camera_active = true, + camera_nui_focused = false, + is_open = true, +}) +assert( + camera_interrupted_by_call.focused and not camera_interrupted_by_call.keep_input, + "incoming call attention must override unfocused camera input" +) + +local camera_after_connected_call = resolve({ + call_focus = false, + camera_active = true, + camera_nui_focused = false, + is_open = true, +}) +assert( + not camera_after_connected_call.focused and camera_after_connected_call.keep_input, + "connected call without an attention claim must restore unfocused camera input" +) + +local payphone_closed_behind_phone = resolve({ is_open = true, payphone_focus = false }) +assert(payphone_closed_behind_phone.focused, "releasing payphone focus must not clear mobile phone focus") + +local suspended = resolve({ activity_suspended = true, call_focus = true, is_open = true }) +assert(not suspended.focused and not suspended.keep_input, "suspended activities must mask every focus claim") + +print("Client focus tests passed") diff --git a/tests/server_payphones.lua b/tests/server_payphones.lua new file mode 100644 index 0000000..1798a08 --- /dev/null +++ b/tests/server_payphones.lua @@ -0,0 +1,39 @@ +dofile("sky_phone/source/server/payphones.lua") + +local allowed_models = { + prop_phonebox_01a = true, + prop_phonebox_04 = true, +} + +local locations, rejected = SkyPhonePayphones.ValidateLocations({ + { model = "prop_phonebox_01a", coords = { x = 100.0, y = 200.0, z = 30.0 } }, + { model = "prop_phonebox_04", coords = { x = 105.0, y = 200.0, z = 30.0 } }, + { model = "not_allowed", coords = { x = 100.0, y = 200.0, z = 30.0 } }, + { model = "prop_phonebox_01a", coords = { x = "100", y = 200.0, z = 30.0 } }, + { model = "prop_phonebox_01a", coords = { x = 10001.0, y = 200.0, z = 30.0 } }, + "malformed", +}, allowed_models) + +assert(#locations == 2, "only strictly valid configured locations must be accepted") +assert(rejected == 4, "every malformed or disallowed configured location must be reported") + +local first = SkyPhonePayphones.FindNearest(locations, { x = 101.0, y = 200.0, z = 30.0 }, 3.0) +assert(first and first.model == "prop_phonebox_01a", "nearest configured booth must be selected") + +local second = SkyPhonePayphones.FindNearest(locations, { x = 104.0, y = 200.0, z = 30.0 }, 3.0) +assert(second and second.model == "prop_phonebox_04", "another configured booth must be selected by proximity") + +assert( + not SkyPhonePayphones.FindNearest(locations, { x = 0.0, y = 0.0, z = 0.0 }, 3.0), + "a player away from every configured booth must be rejected" +) +assert( + not SkyPhonePayphones.FindNearest(locations, { x = "100", y = 200.0, z = 30.0 }, 3.0), + "malformed player coordinates must be rejected" +) +assert( + not SkyPhonePayphones.FindNearest(locations, { x = 100.0, y = 200.0, z = 30.0 }, 0.0), + "an invalid validation distance must be rejected" +) + +print("Server payphone validation tests passed")