ENH - merge dev into Companies feature

This commit is contained in:
Leon.Schmidt
2026-08-11 16:31:27 +02:00
24 changed files with 5113 additions and 438 deletions
+61 -9
View File
@@ -88,16 +88,68 @@ set sky_phone_fliptok_password_pepper "replace-with-a-long-random-secret"
Passwords are stored as salted hashes. The pepper is read server-side from the convar and is never
included in the NUI bundle.
Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly.
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`.
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
Both compatibility switches default to the physical, unique-item behavior:
```lua
Config.Phone.Unique = true
Config.Sim.Enabled = true
```
For backwards compatibility, an omitted switch is also treated as `true`.
With `Config.Phone.Unique = true`, every phone item is one transferable physical Device. Its unique
15-digit IMEI is stored in item metadata, so its settings, PIN, local app data, linked iFruit
account, and installed SIM travel with that item. The item must be non-stackable.
With `Config.Phone.Unique = false`, the server assigns each framework character one persistent
virtual Device. Possessing any configured phone item grants access to that Device, but the item does
not own its IMEI or data. Losing a handset therefore removes access only until the character obtains
another one; the replacement opens the same data, PIN, account session, and number. Phone items may
be stackable in this mode, although at least one phone item is still required to use or receive the
phone. The character mapping is server-authoritative and uses the framework character identifier,
never a client-supplied owner value.
With `Config.Sim.Enabled = true`, a physical registered or anonymous SIM item must be inserted before
the Device has cellular service. SIM items remain unique and non-stackable. With
`Config.Sim.Enabled = false`, SIM inventory items are not required. The first time a Device without
an attached SIM is resolved, the server creates an anonymous virtual SIM with a random unique phone
number using `Config.Sim.NumberPrefix` and `NumberLength`; `NumberGroups` controls its display
format. Physical SIM insertion and ejection are disabled in this mode. An already attached physical
SIM and its number are preserved when SIM requirements are disabled.
The switches are independent. An automatic number follows a physical handset when unique phones are
enabled and follows the character's persistent virtual Device when unique phones are disabled.
Likewise, a physical SIM inserted while unique phones are disabled belongs to the character's
virtual Device and remains available after replacing the handset.
### Existing installations and first use
After changing an existing installation to `Config.Phone.Unique = false`, the first phone item a
character uses establishes the persistent mapping. If that item has a valid legacy IMEI which is not
already mapped to another character, the complete existing Device is adopted. This preserves its
local content, PIN, linked account, and attached SIM. If the legacy IMEI cannot be adopted, the
server creates a fresh virtual Device instead. Historic unique Devices did not record an owner, so
the character carrying a handset on its first use after the change is the character that claims it.
When `Config.Sim.Enabled = false`, an existing attached physical SIM is kept; only Devices without a
SIM receive a random virtual one. If SIM cards are enabled again, automatically created virtual SIMs
are detached during resource startup so those Devices once again require a physical SIM. Stored
character-to-Device mappings remain available, but changing back to unique phones does not copy a
virtual Device's data onto an arbitrary inventory item. Treat production mode changes as migrations
and restart the resource after updating the configuration.
## Requirements
- ESX Legacy (`es_extended`), Qbox (`qbx_core`), or QBCore (`qb-core`). The bridge selects a running supported framework when `Config.Bridge.Framework` is set to `"auto"`.
- A supported inventory: `ox_inventory`, `qb-inventory`, `lj-inventory`, `qs-inventory`, `codem-inventory`, `core_inventory`, `mf-inventory`, or `smx-inventory`. The bridge auto-detects a running provider and normalizes metadata, slots, counts, item mutations, and usable-item callbacks. `mf-inventory` and `smx-inventory` require ESX. Because SMX stores standard ESX items as stacks, its adapter persists one active Phone/SIM metadata record per player and item type in ESX player metadata.
- A non-stackable inventory item named `phone`.
- 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.
- An inventory item named `phone`. It must be non-stackable when `Config.Phone.Unique = true` and may be stackable when it is `false`.
- 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
@@ -118,7 +170,7 @@ GIPHY provides trending and searched GIFs through a paginated server-side proxy.
only the server uses the GIPHY key. Photo and video actions in Messages are intentionally inactive
until their dedicated implementation is available.
Database migrations run automatically. Existing `sky_phone_mail_accounts` installations are renamed to `sky_phone_accounts` while preserving account IDs and mail foreign keys. iFruit passwords are intentional in-character credentials and remain plaintext `VARCHAR(64)` values; registration screens warn players never to reuse a real password.
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.
Camera and Gallery media is stored in `sky_phone_media`. Signed-out captures belong to the current
IMEI; linking an iFruit account moves those rows into the account gallery so every linked phone sees
@@ -137,13 +189,13 @@ Configure frequency bounds and precision, restricted channel ranges and allowed
Radio profiles are stored in `sky_phone_radio_profiles`. Runtime migration creates the table automatically; fresh installations receive it through `sky_phone/sql/install.sql`.
Inventory metadata has no framework-wide standard: providers differ in export names, callback payloads, slot handling, and whether metadata is called `metadata` or `info`. For that reason, `sky_phone` uses explicit provider adapters instead of guessing exports at runtime. Every supported adapter implements slot lookup, item lookup, metadata replacement, capacity handling, add/remove operations, and usable-item registration. Providers without a separate capacity export use their authoritative add operation as the final capacity gate.
Inventory metadata has no framework-wide standard: providers differ in export names, callback payloads, slot handling, and whether metadata is called `metadata` or `info`. For that reason, `sky_phone` uses explicit provider adapters instead of guessing exports at runtime. Every supported adapter implements slot lookup, item lookup, metadata replacement, capacity handling, add/remove operations, and usable-item registration. Providers without a separate capacity export use their authoritative add operation as the final capacity gate. Phone item metadata is authoritative only when `Config.Phone.Unique = true`; in non-unique mode the persistent character mapping is authoritative instead.
When a SIM is ejected or replaced, the returned inventory item is rebuilt from the authoritative `sky_phone_sims` row. Its metadata contains `sim_metadata_version`, `sim_id`, `phone_number`, `formatted_number`, and `sim_type`. Registered SIMs additionally contain `firstname`, `lastname`, `birthdate`, and `registered_at`. The internal framework owner identifier remains database-only. Inserting the item again resolves the SIM by `sim_id`; contacts and device/cloud data remain attached to their existing phone-owned persistence instead of being copied into inventory metadata.
When physical SIMs are enabled and a SIM is ejected or replaced, the returned inventory item is rebuilt from the authoritative `sky_phone_sims` row. Its metadata contains `sim_metadata_version`, `sim_id`, `phone_number`, `formatted_number`, and `sim_type`. Registered SIMs additionally contain `firstname`, `lastname`, `birthdate`, and `registered_at`. The internal framework owner identifier remains database-only. Inserting the item again resolves the SIM by `sim_id`; contacts and device/cloud data remain attached to their existing Device or cloud persistence instead of being copied into inventory metadata. Automatically created virtual SIMs remain database-only and never become inventory items.
For `ox_inventory`, configure all three items with `stack = false` and `consume = 0`. Do not configure a client event or export. Ox then completes its normal server-authoritative use flow and emits `ox_inventory:usedItem`; the bridge resolves the authoritative slot again and only opens the matching device or SIM. A client export would return before Ox calls `useItem` and therefore prevent `ox_inventory:usedItem` from being emitted.
For `ox_inventory`, configure the phone with `stack = false` when `Config.Phone.Unique = true`; it may use `stack = true` in non-unique mode. Physical SIM items always use `stack = false` and are only needed when `Config.Sim.Enabled = true`. Every usable item should use `consume = 0`. Do not configure a client event or export. Ox then completes its normal server-authoritative use flow and emits `ox_inventory:usedItem`; the bridge resolves the authoritative slot again and only opens the matching Device or SIM. A client export would return before Ox calls `useItem` and therefore prevent `ox_inventory:usedItem` from being emitted.
Example `ox_inventory/data/items.lua` entries:
Example `ox_inventory/data/items.lua` entries for the default unique-phone, physical-SIM modes:
```lua
["phone"] = {
@@ -169,7 +221,7 @@ Example `ox_inventory/data/items.lua` entries:
},
```
For QBCore-style item tables, define the same names with `unique = true`, `useable = true`, and `shouldClose = true`. The provider adapter registers the server-side usable callbacks; no `lb-phone` event or export is used.
For QBCore-style item tables, set the phone's `unique` value to match `Config.Phone.Unique`, and use `useable = true` and `shouldClose = true`. When physical SIMs are enabled, define both SIM items with `unique = true`, `useable = true`, and `shouldClose = true`; omit them when SIMs are disabled. The provider adapter registers the server-side usable callbacks; no `lb-phone` event or export is used.
The homescreen is an original implementation inspired by the interaction and layout concepts in [lukejacksonn/homescreen](https://github.com/lukejacksonn/homescreen), inspected at commit [`98a812f`](https://github.com/lukejacksonn/homescreen/tree/98a812f4f7c33594e791d65092f73b8f54b3c598). No source code or image assets from that project are included.
+12
View File
@@ -261,6 +261,9 @@ const notifications = useNotificationsStore()
const route = useRoute()
const router = useRouter()
const isAppRoute = computed(() => route.name === 'app')
const showActiveCallReturn = computed(
() => Boolean(calls.activeCall) && route.params.appId !== 'phone',
)
const appTransitionName = computed(() =>
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
)
@@ -407,6 +410,7 @@ async function hydrateDevelopmentPhone(): Promise<void> {
sim: {
id: 'development-sim',
number: '5551234567',
removable: true,
registered: true,
type: 'registered',
},
@@ -974,6 +978,12 @@ function lockPhone(): void {
isLocked.value = true
}
function returnToActiveCall(): void {
if (!calls.activeCall) return
controlCenterOpened.value = false
void router.push('/apps/phone')
}
function unlockCamera(): void {
if (phone.security.enabled) {
pendingUnlockRoute.value = '/apps/camera'
@@ -1205,8 +1215,10 @@ onBeforeUnmount(() => {
>
<PhoneStatusBar
v-if="!isLocked"
:active-call-return="showActiveCallReturn"
:control-center-opened="controlCenterOpened"
lockable
@active-call="returnToActiveCall"
@control-center="toggleControlCenter"
@lock="lockPhone"
/>
+41
View File
@@ -467,6 +467,47 @@ button {
cursor: pointer;
pointer-events: auto;
}
.phone-status-bar__time-button--active-call {
isolation: isolate;
text-shadow: none;
}
.phone-status-bar__time-button--active-call::before {
position: absolute;
z-index: -1;
top: 19px;
left: calc(50% - 3px);
width: 90px;
height: 30px;
border-radius: 15px;
background: #34c759;
box-shadow: 0 2px 8px #0b6f2b66;
animation: phone-active-call-pill-in 220ms cubic-bezier(0.22, 1, 0.36, 1);
content: '';
transform-origin: center;
transform: translateX(-50%);
transition: transform 140ms ease;
}
.phone-status-bar__time-button--active-call:active::before {
transform: translateX(-50%) scale(0.94);
}
.phone-status-bar__active-call-icon {
position: absolute;
top: 27px;
left: calc(50% - 39px);
width: 13px;
height: 13px;
stroke-width: 2.5;
}
@keyframes phone-active-call-pill-in {
from {
opacity: 0;
transform: translateX(-50%) scale(0.82);
}
to {
opacity: 1;
transform: translateX(-50%) scale(1);
}
}
.phone-status-bar__indicators {
position: absolute;
top: 0;
+31 -5
View File
@@ -1,17 +1,23 @@
<script setup lang="ts">
import { PhoneCall } from 'lucide-vue-next'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue'
import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
withDefaults(
defineProps<{ controlCenterOpened?: boolean; lockable?: boolean }>(),
const props = withDefaults(
defineProps<{
activeCallReturn?: boolean
controlCenterOpened?: boolean
lockable?: boolean
}>(),
{
activeCallReturn: false,
controlCenterOpened: false,
lockable: false,
},
)
const emit = defineEmits<{ controlCenter: []; lock: [] }>()
const emit = defineEmits<{ activeCall: []; controlCenter: []; lock: [] }>()
const time = ref('')
let intervalId: number | undefined
@@ -23,6 +29,14 @@ function updateTime(): void {
}).format(new Date())
}
function handleTimeClick(): void {
if (props.activeCallReturn) {
emit('activeCall')
return
}
emit('lock')
}
onMounted(() => {
updateTime()
intervalId = window.setInterval(updateTime, 30_000)
@@ -38,10 +52,22 @@ onBeforeUnmount(() => {
<button
v-if="lockable"
class="phone-status-bar__time phone-status-bar__time-button"
:class="{
'phone-status-bar__time-button--active-call': activeCallReturn,
}"
type="button"
:aria-label="phone.t('LockScreen.label')"
@click.stop="emit('lock')"
:aria-label="
phone.t(
activeCallReturn ? 'Apps.phone.returnToCall' : 'LockScreen.label',
)
"
@click.stop="handleTimeClick"
>
<PhoneCall
v-if="activeCallReturn"
class="phone-status-bar__active-call-icon"
aria-hidden="true"
/>
<time>{{ time }}</time>
</button>
<time v-else class="phone-status-bar__time">{{ time }}</time>
+50
View File
@@ -68,4 +68,54 @@ describe('calls store', () => {
expect(calls.activeCall).toBeNull()
expect(nuiCall).toHaveBeenCalledWith('calls:recents')
})
it('blocks the active caller and closes the call screen', async () => {
const calls = useCallsStore()
calls.applyCallState({
direction: 'incoming',
id: 'call-3',
otherNumber: '5551110025',
startedAt: 1,
state: 'ringing',
})
const response = await calls.blockNumber('5551110025')
expect(response.success).toBe(true)
expect(nuiCall).toHaveBeenCalledWith('calls:block', {
phoneNumber: '5551110025',
})
expect(calls.activeCall).toBeNull()
expect(nuiCall).toHaveBeenCalledWith('calls:recents')
})
it('updates a contact favorite and refreshes the contact list', async () => {
vi.mocked(nuiCall)
.mockResolvedValueOnce({
success: true,
data: { favorite: true, id: 'contact-alex' },
})
.mockResolvedValueOnce({
success: true,
data: [
{
favorite: true,
id: 'contact-alex',
name: 'Alex Rivera',
phone_number: '5551110001',
},
],
})
const calls = useCallsStore()
const response = await calls.setContactFavorite('contact-alex', true)
expect(response.success).toBe(true)
expect(nuiCall).toHaveBeenNthCalledWith(1, 'contacts:favorite', {
favorite: true,
id: 'contact-alex',
})
expect(nuiCall).toHaveBeenNthCalledWith(2, 'contacts:list')
expect(calls.contacts[0]?.favorite).toBe(true)
})
})
+47 -3
View File
@@ -35,8 +35,11 @@ export const useCallsStore = defineStore('calls', () => {
}
async function saveContact(contact: {
avatarMediaId?: number | null
id?: string
name: string
notes?: string
organization?: string
phoneNumber: string
}): Promise<NuiResponse<PhoneContact>> {
const response = await nuiCall<PhoneContact>('contacts:save', contact)
@@ -58,17 +61,56 @@ export const useCallsStore = defineStore('calls', () => {
async function answer(): Promise<NuiResponse> {
if (!activeCall.value) return { success: false, error: 'call_not_found' }
return nuiCall('calls:answer', { id: activeCall.value.id })
const response = await nuiCall('calls:answer', { id: activeCall.value.id })
if (response.success && activeCall.value) {
activeCall.value = {
...activeCall.value,
answeredAt: activeCall.value.answeredAt ?? Date.now(),
state: 'connected',
}
}
return response
}
async function decline(): Promise<boolean> {
if (!activeCall.value) return false
return (await nuiCall('calls:decline', { id: activeCall.value.id })).success
const response = await nuiCall('calls:decline', { id: activeCall.value.id })
if (response.success) activeCall.value = null
return response.success
}
async function setContactFavorite(
id: string,
favorite: boolean,
): Promise<NuiResponse<{ favorite: boolean; id: string }>> {
const response = await nuiCall<{ favorite: boolean; id: string }>(
'contacts:favorite',
{ favorite, id },
)
if (response.success) await loadContacts()
return response
}
async function hangup(): Promise<boolean> {
if (!activeCall.value) return false
return (await nuiCall('calls:hangup', { id: activeCall.value.id })).success
const response = await nuiCall('calls:hangup', { id: activeCall.value.id })
if (response.success) {
activeCall.value = null
await loadRecents()
}
return response.success
}
async function blockNumber(phoneNumber: string): Promise<NuiResponse> {
const response = await nuiCall('calls:block', { phoneNumber })
if (
response.success &&
activeCall.value?.otherNumber === phoneNumber
) {
activeCall.value = null
await loadRecents()
}
return response
}
function applyCallState(call: PhoneCall): void {
@@ -95,6 +137,7 @@ export const useCallsStore = defineStore('calls', () => {
answer,
applyCallState,
bootstrap,
blockNumber,
contacts,
decline,
deleteContact,
@@ -104,5 +147,6 @@ export const useCallsStore = defineStore('calls', () => {
loadRecents,
recents,
saveContact,
setContactFavorite,
}
})
+52
View File
@@ -1312,6 +1312,7 @@ const defaultLocales: LocaleTree = {
addContact: 'Add Contact',
contactSaved: 'Contact saved',
removeContact: 'Remove Contact',
returnToCall: 'Return to Call',
block: 'Block User',
unblock: 'Unblock User',
clearChat: 'Clear Chat',
@@ -1472,14 +1473,60 @@ const defaultLocales: LocaleTree = {
noSim: 'No SIM',
noSimBody: 'Insert a SIM card in Settings to make calls.',
noRecents: 'No Recent Calls',
allCalls: 'All',
missedCalls: 'Missed',
searchRecents: 'Search Calls',
contactDetails: 'Contact Details',
unknownCaller: 'Unknown Caller',
callHistory: 'Call History',
noCallHistory: 'No calls with this number yet.',
noContacts: 'No Contacts',
favorites: 'Favorites',
searchContacts: 'Search Contacts',
myCard: 'My Card',
myNumber: 'My Number',
device: 'Device',
contactIndex: 'Contact Index',
addContact: 'New Contact',
newContact: 'New',
editContact: 'Edit Contact',
contactName: 'Name',
firstName: 'First Name',
lastName: 'Last Name',
companyOrGroup: 'Company or Group',
phoneNumber: 'Phone Number',
officialContact: 'Official company contact',
choosePhoto: 'Choose Contact Photo',
chooseGallery: 'Gallery',
takePhoto: 'Camera',
removePhoto: 'Remove Photo',
call: 'Call',
message: 'Message',
video: 'Video',
mail: 'Mail',
defaultLabel: 'Default',
privateLabel: 'Private',
contactCard: 'Contact Card',
removeContact: 'Remove Contact',
mobile: 'Mobile',
messagesProfile: 'Messages',
notes: 'Notes',
sendMessage: 'Send Message',
shareContact: 'Share Contact',
addFavorite: 'Add to Favorites',
removeFavorite: 'Remove from Favorites',
addEmergency: 'Add to Emergency Contacts',
blockContact: 'Block Contact',
block: 'Block',
blockCaller: 'Block Caller',
blockCallerTitle: 'Block this caller?',
blockCallerBody: '{number} will no longer be able to call this SIM.',
faceTime: 'FaceTime',
hideKeypad: 'Hide Keypad',
more: 'More',
mute: 'Mute',
speaker: 'Speaker',
viewContact: 'View Contact',
calling: 'calling...',
incoming: 'Incoming Call',
incomingDirection: 'Incoming',
@@ -1508,9 +1555,14 @@ const defaultLocales: LocaleTree = {
invalid_contact: 'Enter a name and valid phone number.',
invalid_number: 'Enter a valid phone number.',
invalid_sim: 'The active SIM card is unavailable.',
message_unavailable: 'The conversation could not be opened.',
contact_remove_failed: 'The contact could not be removed.',
contact_favorite_failed: 'The favorite could not be updated.',
no_sim: 'This phone has no SIM card.',
airplane_mode: 'Turn off Airplane Mode to make calls.',
self_call: 'You cannot call your own number.',
blocked: 'This number is blocked.',
recipient_not_found: 'This number is not known.',
busy: 'The line is busy.',
company_unavailable: 'This company service line is unavailable.',
readonly_contact: 'Official company contacts cannot be changed.',
+6
View File
@@ -3,18 +3,24 @@ export type SimType = 'registered' | 'anonymous'
export type PhoneSim = {
id: string
number: string
removable: boolean
registered: boolean
type: SimType
}
export type PhoneContact = {
avatar_media_id?: number | null
avatar_url?: string | null
canCall?: boolean
canMessage?: boolean
companyId?: string
created_at?: string
favorite?: boolean | number
id: string
icon?: string
name: string
notes?: string | null
organization?: string | null
phone_number: string
readonly?: boolean
source?: 'personal' | 'company'
+14 -4
View File
@@ -288,6 +288,7 @@ onBeforeUnmount(() => {
</div>
<template v-else-if="billing.detail">
<kGlass
:highlight="false"
class="billing-detail__hero"
:class="{
'billing-detail__hero--paid': billing.detail.status === 'paid',
@@ -441,17 +442,26 @@ onBeforeUnmount(() => {
<template v-else-if="tab === 'overview' && billing.overview">
<div class="billing-summary">
<kGlass class="billing-summary__item billing-summary__item--open">
<kGlass
:highlight="false"
class="billing-summary__item billing-summary__item--open"
>
<ReceiptText :size="19" />
<span>{{ t('summary.open') }}</span>
<strong>{{ billing.overview.openCount }}</strong>
</kGlass>
<kGlass class="billing-summary__item billing-summary__item--due">
<kGlass
:highlight="false"
class="billing-summary__item billing-summary__item--due"
>
<CalendarDays :size="19" />
<span>{{ t('summary.due') }}</span>
<strong>{{ formatMoney(billing.overview.openTotal) }}</strong>
</kGlass>
<kGlass class="billing-summary__item billing-summary__item--overdue">
<kGlass
:highlight="false"
class="billing-summary__item billing-summary__item--overdue"
>
<AlertTriangle :size="19" />
<span>{{ t('summary.overdue') }}</span>
<strong>{{ billing.overview.overdueCount }}</strong>
@@ -743,7 +753,7 @@ onBeforeUnmount(() => {
/></span>
<h2>{{ t('payment.title') }}</h2>
<p>{{ t('payment.body', { issuer: billing.detail.issuerLabel }) }}</p>
<kGlass class="billing-payment-total">
<kGlass :highlight="false" class="billing-payment-total">
<span>{{ billing.detail.title }}</span>
<strong>{{
formatMoney(billing.detail.amount, billing.detail.currency)
+20 -36
View File
@@ -118,14 +118,24 @@ function queueCapture(id: string, mediaType: MediaType): void {
captures.value = captures.value.slice(0, 6)
}
function devMedia(id: string, mediaType: MediaType): PhoneMedia {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="1600"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#19354f"/><stop offset="1" stop-color="#d78357"/></linearGradient></defs><rect width="900" height="1600" fill="url(#g)"/><circle cx="680" cy="380" r="210" fill="#ffffff22"/><path d="M0 1200 260 840l180 220 170-170 290 310v400H0z" fill="#102331aa"/></svg>`
return {
createdAt: Date.now(),
id: Number(Date.now()),
mediaType,
url: `data:image/svg+xml,${encodeURIComponent(svg)}#${id}`,
}
async function completeDevelopmentCapture(
correlationId: string,
mediaType: MediaType,
): Promise<void> {
const response = await nuiCall<PhoneMedia>('media:devCapture', { mediaType })
window.dispatchEvent(
new MessageEvent('message', {
data: {
data: {
correlationId,
error: response.error,
media: response.data,
success: response.success,
},
type: 'media:uploadResult',
},
}),
)
}
async function requestPhoto(): Promise<void> {
@@ -138,20 +148,7 @@ async function requestPhoto(): Promise<void> {
shutterActive.value = false
}, 280)
if (isDevelopment) {
window.setTimeout(() => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
data: {
correlationId: id,
media: devMedia(id, 'photo'),
success: true,
},
type: 'media:uploadResult',
},
}),
)
}, 700)
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
return
}
await nuiCall('media:requestUpload', {
@@ -196,20 +193,7 @@ function stopRecording(): void {
savingVideo.value = true
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
recordingTimer = undefined
window.setTimeout(() => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
data: {
correlationId: id,
media: devMedia(id, 'video'),
success: true,
},
type: 'media:uploadResult',
},
}),
)
}, 900)
window.setTimeout(() => void completeDevelopmentCapture(id, 'video'), 900)
return
}
window.postMessage(
+2
View File
@@ -407,6 +407,8 @@ async function saveContactDetails(): Promise<void> {
const response = await calls.saveContact({
id: activeContact.value?.id,
name: contactNameDraft.value.trim(),
notes: activeContact.value?.notes ?? '',
organization: activeContact.value?.organization ?? '',
phoneNumber: contactNumberDraft.value,
})
if (!response.success) {
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -1337,7 +1337,7 @@ onBeforeUnmount(() => {
"
/>
<k-list-item
v-if="phone.device?.sim"
v-if="phone.device?.sim?.removable"
:title="phone.t('Apps.settings.simType')"
:after="
phone.t(
@@ -1348,7 +1348,7 @@ onBeforeUnmount(() => {
"
/>
<k-list-button
v-if="phone.device?.sim"
v-if="phone.device?.sim?.removable"
@click="simEjectOpened = true"
>
{{ phone.t('Apps.settings.ejectSim') }}
@@ -1610,7 +1610,11 @@ onBeforeUnmount(() => {
</template>
</k-dialog>
<k-dialog :opened="simEjectOpened" @backdropclick="simEjectOpened = false">
<k-dialog
v-if="phone.device?.sim?.removable"
:opened="simEjectOpened"
@backdropclick="simEjectOpened = false"
>
<template #title>{{ phone.t('Apps.settings.ejectSim') }}</template>
<p>{{ phone.t('Apps.settings.ejectSimBody') }}</p>
<template #buttons>
+544 -76
View File
@@ -1045,8 +1045,60 @@ const mockHousingOverview = {
],
}
let contactSequence = 2
let contactSequence = 40
const contacts = [
{
avatar_media_id: 1,
avatar_url: 'https://picsum.photos/seed/sky-phone-1/600/800',
created_at: isoTime(-42 * 86_400_000),
favorite: true,
id: 'contact-alex',
name: 'Alex Rivera',
notes: 'Meeting on Friday at 18:00 near the bank.',
organization: 'Maze Bank',
phone_number: '5551110001',
updated_at: isoTime(-6 * 86_400_000),
},
{
created_at: isoTime(-38 * 86_400_000),
id: 'contact-alexander',
name: 'Alexander Stone',
phone_number: '5551110002',
updated_at: isoTime(-8 * 86_400_000),
},
{
created_at: isoTime(-21 * 86_400_000),
id: 'contact-andre',
name: 'Andre Heinicke',
phone_number: '5551110003',
updated_at: isoTime(-4 * 86_400_000),
},
{
created_at: isoTime(-34 * 86_400_000),
favorite: true,
id: 'contact-benni',
name: 'Benni Parker',
notes: 'Call about the Sultan RS repair estimate.',
organization: "Benny's Motor Works",
phone_number: '5551110004',
updated_at: isoTime(-12 * 86_400_000),
},
{
avatar_media_id: 3,
avatar_url: 'https://picsum.photos/seed/sky-phone-3/800/600',
created_at: isoTime(-18 * 86_400_000),
id: 'contact-bryce',
name: 'Bryce Walker',
phone_number: '5551110005',
updated_at: isoTime(-7 * 86_400_000),
},
{
created_at: isoTime(-27 * 86_400_000),
id: 'contact-charlie',
name: 'Charlie Davis',
phone_number: '5551110006',
updated_at: isoTime(-11 * 86_400_000),
},
{
created_at: '2026-08-04 12:00:00',
id: 'contact-1',
@@ -1090,6 +1142,185 @@ const contacts = [
source: 'company',
verified: true,
},
{
created_at: isoTime(-14 * 86_400_000),
id: 'contact-morgan',
name: 'Morgan Reed',
phone_number: '5550192847',
updated_at: isoTime(-2 * 86_400_000),
},
{
created_at: isoTime(-30 * 86_400_000),
id: 'contact-jamie',
name: 'Jamie Chen',
phone_number: '5559876543',
updated_at: isoTime(-30 * 86_400_000),
},
{
created_at: isoTime(-5 * 86_400_000),
id: 'contact-mechanic',
name: 'Downtown Customs',
phone_number: '5550100101',
updated_at: isoTime(-5 * 86_400_000),
},
{
avatar_media_id: 4,
avatar_url: 'https://picsum.photos/seed/sky-phone-4/800/600',
created_at: isoTime(-3 * 86_400_000),
favorite: true,
id: 'contact-taxi',
name: 'Los Santos Taxi',
organization: 'Los Santos Taxi Co.',
phone_number: '5552222222',
updated_at: isoTime(-3 * 86_400_000),
},
{
created_at: isoTime(-22 * 86_400_000),
id: 'contact-daniel',
name: 'Daniel Price',
phone_number: '5551110007',
updated_at: isoTime(-3 * 86_400_000),
},
{
created_at: isoTime(-20 * 86_400_000),
id: 'contact-emily',
name: 'Emily Hart',
phone_number: '5551110008',
updated_at: isoTime(-2 * 86_400_000),
},
{
created_at: isoTime(-31 * 86_400_000),
id: 'contact-franklin',
name: 'Franklin Miles',
phone_number: '5551110009',
updated_at: isoTime(-9 * 86_400_000),
},
{
created_at: isoTime(-25 * 86_400_000),
id: 'contact-grace',
name: 'Grace Carter',
phone_number: '5551110010',
updated_at: isoTime(-4 * 86_400_000),
},
{
created_at: isoTime(-19 * 86_400_000),
id: 'contact-hannah',
name: 'Hannah Brooks',
phone_number: '5551110011',
updated_at: isoTime(-5 * 86_400_000),
},
{
created_at: isoTime(-17 * 86_400_000),
id: 'contact-ivan',
name: 'Ivan Petrov',
phone_number: '5551110012',
updated_at: isoTime(-6 * 86_400_000),
},
{
created_at: isoTime(-16 * 86_400_000),
id: 'contact-kevin',
name: 'Kevin Adams',
phone_number: '5551110013',
updated_at: isoTime(-2 * 86_400_000),
},
{
created_at: isoTime(-15 * 86_400_000),
id: 'contact-naomi',
name: 'Naomi King',
phone_number: '5551110014',
updated_at: isoTime(-2 * 86_400_000),
},
{
created_at: isoTime(-13 * 86_400_000),
id: 'contact-olivia',
name: 'Olivia Moore',
phone_number: '5551110015',
updated_at: isoTime(-2 * 86_400_000),
},
{
created_at: isoTime(-12 * 86_400_000),
id: 'contact-paul',
name: 'Paul Walker',
phone_number: '5551110016',
updated_at: isoTime(-4 * 86_400_000),
},
{
created_at: isoTime(-11 * 86_400_000),
id: 'contact-quinn',
name: 'Quinn Bailey',
phone_number: '5551110017',
updated_at: isoTime(-3 * 86_400_000),
},
{
created_at: isoTime(-10 * 86_400_000),
id: 'contact-riley',
name: 'Riley Cooper',
phone_number: '5551110018',
updated_at: isoTime(-2 * 86_400_000),
},
{
created_at: isoTime(-9 * 86_400_000),
id: 'contact-sofia',
name: 'Sofia Bennett',
phone_number: '5551110019',
updated_at: isoTime(-2 * 86_400_000),
},
{
created_at: isoTime(-8 * 86_400_000),
id: 'contact-thomas',
name: 'Thomas Reed',
phone_number: '5551110020',
updated_at: isoTime(-2 * 86_400_000),
},
{
created_at: isoTime(-7 * 86_400_000),
id: 'contact-ursula',
name: 'Ursula Grant',
phone_number: '5551110021',
updated_at: isoTime(-2 * 86_400_000),
},
{
created_at: isoTime(-6 * 86_400_000),
id: 'contact-victor',
name: 'Victor Young',
phone_number: '5551110022',
updated_at: isoTime(-2 * 86_400_000),
},
{
created_at: isoTime(-5 * 86_400_000),
id: 'contact-wendy',
name: 'Wendy Clark',
phone_number: '5551110023',
updated_at: isoTime(-2 * 86_400_000),
},
{
created_at: isoTime(-4 * 86_400_000),
id: 'contact-xavier',
name: 'Xavier Cole',
phone_number: '5551110024',
updated_at: isoTime(-2 * 86_400_000),
},
{
created_at: isoTime(-3 * 86_400_000),
id: 'contact-yakup',
name: 'Yakup Broooooo',
phone_number: '5551110025',
updated_at: isoTime(-86_400_000),
},
{
created_at: isoTime(-2 * 86_400_000),
id: 'contact-zoe',
name: 'Zoe Martinez',
phone_number: '5551110026',
updated_at: isoTime(-86_400_000),
},
{
created_at: isoTime(-86_400_000),
id: 'contact-market',
name: '24/7 Supermarket',
phone_number: '5552470000',
updated_at: isoTime(-3_600_000),
},
]
const attachmentAssets = {
gif: new Set(['celebrate', 'hearts', 'party', 'thumbs_up', 'wow']),
@@ -1219,6 +1450,126 @@ const smsMessages = [
recipient_number: '5558675309',
sender_number: '5551234567',
},
{
body: 'Das Fahrzeug ist fertig. Du kannst es jederzeit abholen.',
created_at: isoTime(-26 * 60 * 60_000),
direction: 'received',
id: 'sms-customs-1',
media_duration_ms: null,
media_mime: null,
media_payload: null,
media_waveform: null,
message_type: 'text',
media_asset_id: null,
read_at: isoTime(-25 * 60 * 60_000),
recipient_number: '5551234567',
sender_number: '5550100101',
},
{
body: 'Perfekt, ich komme heute Abend vorbei.',
created_at: isoTime(-25 * 60 * 60_000),
direction: 'sent',
id: 'sms-customs-2',
media_duration_ms: null,
media_mime: null,
media_payload: null,
media_waveform: null,
message_type: 'text',
media_asset_id: null,
read_at: isoTime(-25 * 60 * 60_000),
recipient_number: '5550100101',
sender_number: '5551234567',
},
{
body: 'Treffen wir uns um 20 Uhr am Casino?',
created_at: isoTime(-7 * 60 * 60_000),
direction: 'received',
id: 'sms-morgan-1',
media_duration_ms: null,
media_mime: null,
media_payload: null,
media_waveform: null,
message_type: 'text',
media_asset_id: null,
read_at: isoTime(-6 * 60 * 60_000),
recipient_number: '5551234567',
sender_number: '5550192847',
},
{
body: 'Ja, passt. Ich bin puenktlich da.',
created_at: isoTime(-6 * 60 * 60_000),
direction: 'sent',
id: 'sms-morgan-2',
media_duration_ms: null,
media_mime: null,
media_payload: null,
media_waveform: null,
message_type: 'text',
media_asset_id: null,
read_at: isoTime(-6 * 60 * 60_000),
recipient_number: '5550192847',
sender_number: '5551234567',
},
{
body: 'Bin in zehn Minuten bei dir.',
created_at: isoTime(-95 * 60_000),
direction: 'received',
id: 'sms-jamie-1',
media_duration_ms: null,
media_mime: null,
media_payload: null,
media_waveform: null,
message_type: 'text',
media_asset_id: null,
read_at: null,
recipient_number: '5551234567',
sender_number: '5559876543',
},
{
body: 'Dein Taxi wartet vor dem Haupteingang.',
created_at: isoTime(-38 * 60_000),
direction: 'received',
id: 'sms-taxi-1',
media_duration_ms: null,
media_mime: null,
media_payload: null,
media_waveform: null,
message_type: 'text',
media_asset_id: null,
read_at: null,
recipient_number: '5551234567',
sender_number: '5552222222',
},
{
body: 'Danke, ich komme sofort raus.',
created_at: isoTime(-36 * 60_000),
direction: 'sent',
id: 'sms-taxi-2',
media_duration_ms: null,
media_mime: null,
media_payload: null,
media_waveform: null,
message_type: 'text',
media_asset_id: null,
read_at: isoTime(-35 * 60_000),
recipient_number: '5552222222',
sender_number: '5551234567',
},
{
body: 'Denk bitte an die Unterlagen fuer morgen.',
created_at: isoTime(-12 * 60_000),
direction: 'received',
id: 'sms-alex-1',
media_duration_ms: null,
media_mime: null,
media_payload: null,
media_waveform: null,
message_type: 'text',
media_asset_id: null,
read_at: null,
recipient_number: '5551234567',
sender_number: '5551110001',
},
]
const darkChatProfile = {
id: 1,
@@ -1663,73 +2014,143 @@ const deviceData = {
}
let mockPasscode = ''
let mockSecurity = { enabled: false, length: null, lockedUntil: 0 }
let mockContacts = [
{
created_at: isoTime(-14 * 86_400_000),
id: 'contact-morgan',
name: 'Morgan Reed',
phone_number: '5550192847',
updated_at: isoTime(-2 * 86_400_000),
},
{
created_at: isoTime(-30 * 86_400_000),
id: 'contact-jamie',
name: 'Jamie Chen',
phone_number: '5559876543',
updated_at: isoTime(-30 * 86_400_000),
},
{
created_at: isoTime(-5 * 86_400_000),
id: 'contact-mechanic',
name: 'Downtown Customs',
phone_number: '5550100101',
updated_at: isoTime(-5 * 86_400_000),
},
{
created_at: isoTime(-3 * 86_400_000),
id: 'contact-taxi',
name: 'Los Santos Taxi',
phone_number: '5552222222',
updated_at: isoTime(-3 * 86_400_000),
},
]
const blockedCallNumbers = new Set()
let recentCalls = [
{
call_id: 'call-alex-incoming',
created_at: isoTime(-8 * 60_000),
direction: 'incoming',
duration_seconds: 184,
id: 1,
other_number: '5551110001',
status: 'completed',
},
{
call_id: 'call-morgan-incoming',
created_at: isoTime(-18 * 60_000),
direction: 'incoming',
duration_seconds: 246,
id: 1,
id: 2,
other_number: '5550192847',
status: 'completed',
},
{
call_id: 'call-taxi-outgoing',
created_at: isoTime(-42 * 60_000),
direction: 'outgoing',
duration_seconds: 39,
id: 3,
other_number: '5552222222',
status: 'completed',
},
{
call_id: 'call-jamie-missed',
created_at: isoTime(-95 * 60_000),
direction: 'incoming',
duration_seconds: 0,
id: 2,
id: 4,
other_number: '5559876543',
status: 'missed',
},
{
call_id: 'call-emily-no-answer',
created_at: isoTime(-4 * 60 * 60_000),
direction: 'outgoing',
duration_seconds: 0,
id: 5,
other_number: '5551110008',
status: 'no_answer',
},
{
call_id: 'call-yakup-incoming',
created_at: isoTime(-8 * 60 * 60_000),
direction: 'incoming',
duration_seconds: 521,
id: 6,
other_number: '5551110025',
status: 'completed',
},
{
call_id: 'call-customs-outgoing',
created_at: isoTime(-25 * 60 * 60_000),
direction: 'outgoing',
duration_seconds: 83,
id: 3,
id: 7,
other_number: '5550100101',
status: 'completed',
},
{
call_id: 'call-unknown-declined',
created_at: isoTime(-2 * 86_400_000),
created_at: isoTime(-27 * 60 * 60_000),
direction: 'incoming',
duration_seconds: 0,
id: 4,
id: 8,
other_number: '5554040404',
status: 'declined',
},
{
call_id: 'call-morgan-outgoing',
created_at: isoTime(-2 * 86_400_000),
direction: 'outgoing',
duration_seconds: 72,
id: 9,
other_number: '5550192847',
status: 'completed',
},
{
call_id: 'call-alex-missed',
created_at: isoTime(-3 * 86_400_000),
direction: 'incoming',
duration_seconds: 0,
id: 10,
other_number: '5551110001',
status: 'missed',
},
{
call_id: 'call-benni-busy',
created_at: isoTime(-4 * 86_400_000),
direction: 'outgoing',
duration_seconds: 0,
id: 11,
other_number: '5551110004',
status: 'busy',
},
{
call_id: 'call-sofia-incoming',
created_at: isoTime(-5 * 86_400_000),
direction: 'incoming',
duration_seconds: 116,
id: 12,
other_number: '5551110019',
status: 'completed',
},
{
call_id: 'call-xavier-unavailable',
created_at: isoTime(-6 * 86_400_000),
direction: 'outgoing',
duration_seconds: 0,
id: 13,
other_number: '5551110024',
status: 'unavailable',
},
{
call_id: 'call-market-outgoing',
created_at: isoTime(-7 * 86_400_000),
direction: 'outgoing',
duration_seconds: 51,
id: 14,
other_number: '5552470000',
status: 'completed',
},
{
call_id: 'call-unknown-missed',
created_at: isoTime(-8 * 86_400_000),
direction: 'incoming',
duration_seconds: 0,
id: 15,
other_number: '5559090909',
status: 'missed',
},
]
let mockMedia = [
{
@@ -6044,6 +6465,7 @@ app.post('/api/:endpoint', (request, response) => {
sim: {
id: 'development-sim',
number: '5551234567',
removable: true,
registered: true,
type: 'registered',
},
@@ -6175,8 +6597,20 @@ app.post('/api/:endpoint', (request, response) => {
}
if (endpoint === 'contacts:save') {
const name = String(request.body.name ?? '').trim()
const notes = String(request.body.notes ?? '')
.trim()
.slice(0, 500)
const organization = String(request.body.organization ?? '')
.trim()
.slice(0, 80)
const phoneNumber = String(request.body.phoneNumber ?? '').trim()
if (!name || !phoneNumber) {
const avatarMediaId = Number(request.body.avatarMediaId) || 0
const avatarMedia = avatarMediaId
? mockMedia.find(
(item) => item.id === avatarMediaId && item.mediaType === 'photo',
)
: null
if (!name || !phoneNumber || (avatarMediaId && !avatarMedia)) {
response.json({ success: false, error: 'invalid_contact' })
return
}
@@ -6187,7 +6621,11 @@ app.post('/api/:endpoint', (request, response) => {
}
if (contact) {
contact.name = name
contact.notes = notes || null
contact.organization = organization || null
contact.phone_number = phoneNumber
contact.avatar_media_id = avatarMedia?.id ?? null
contact.avatar_url = avatarMedia?.url ?? null
contact.updated_at = new Date()
.toISOString()
.slice(0, 19)
@@ -6196,9 +6634,14 @@ app.post('/api/:endpoint', (request, response) => {
const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
contact = {
created_at: now,
favorite: false,
id: `contact-${contactSequence++}`,
name,
notes: notes || null,
organization: organization || null,
phone_number: phoneNumber,
avatar_media_id: avatarMedia?.id ?? null,
avatar_url: avatarMedia?.url ?? null,
updated_at: now,
}
contacts.push(contact)
@@ -6206,6 +6649,20 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: contact })
return
}
if (endpoint === 'contacts:favorite') {
const contact = contacts.find((item) => item.id === request.body.id)
if (!contact || typeof request.body.favorite !== 'boolean') {
response.json({ success: false, error: 'contact_not_found' })
return
}
contact.favorite = request.body.favorite
contact.updated_at = new Date().toISOString().slice(0, 19).replace('T', ' ')
response.json({
success: true,
data: { favorite: contact.favorite, id: contact.id },
})
return
}
if (endpoint === 'contacts:delete') {
const index = contacts.findIndex((item) => item.id === request.body.id)
if (index >= 0 && contacts[index].readonly) {
@@ -6220,6 +6677,27 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: { videoBitrateKbps: 1500 } })
return
}
if (endpoint === 'media:devCapture') {
const mediaType = request.body.mediaType === 'video' ? 'video' : 'photo'
const id = Math.max(0, ...mockMedia.map((item) => Number(item.id) || 0)) + 1
const fallbackVideo = mockMedia.find((item) => item.mediaType === 'video')
const media = {
createdAt: Date.now(),
id,
mediaType,
url:
mediaType === 'photo'
? `https://picsum.photos/seed/sky-camera-${id}/900/1600`
: fallbackVideo?.url,
}
if (!media.url) {
response.json({ success: false, error: 'unsupported' })
return
}
mockMedia.unshift(media)
response.json({ success: true, data: media })
return
}
if (endpoint === 'gallery:list') {
if (request.body.mockState === 'error') {
response.json({ success: false, error: 'service_unavailable' })
@@ -6344,55 +6822,32 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true })
return
}
if (endpoint === 'contacts:list') {
response.json({ success: true, data: mockContacts })
return
}
if (endpoint === 'contacts:save') {
const now = new Date().toISOString()
const existing = mockContacts.find(
(contact) => contact.id === request.body.id,
)
if (existing) {
Object.assign(existing, {
name: request.body.name,
phone_number: request.body.phoneNumber,
updated_at: now,
})
response.json({ success: true, data: existing })
return
}
const contact = {
created_at: now,
id: `contact-${Date.now()}`,
name: request.body.name,
phone_number: request.body.phoneNumber,
updated_at: now,
}
mockContacts.push(contact)
response.json({ success: true, data: contact })
return
}
if (endpoint === 'contacts:delete') {
mockContacts = mockContacts.filter(
(contact) => contact.id !== request.body.id,
)
response.json({ success: true })
return
}
if (endpoint === 'calls:recents') {
response.json({ success: true, data: recentCalls })
return
}
if (endpoint === 'calls:dial') {
const phoneNumber = String(request.body.phoneNumber ?? '').replace(
/\D/g,
'',
)
if (phoneNumber.length !== 10) {
response.json({ success: false, error: 'invalid_number' })
return
}
if (!contacts.some((contact) => contact.phone_number === phoneNumber)) {
response.json({ success: false, error: 'recipient_not_found' })
return
}
const id = `call-${Date.now()}`
const startedAt = Date.now()
recentCalls.unshift({
call_id: id,
created_at: Date.now(),
direction: 'outgoing',
duration_seconds: 0,
id: recentCalls.length + 1,
other_number: request.body.phoneNumber,
other_number: phoneNumber,
status: 'completed',
})
response.json({
@@ -6400,13 +6855,26 @@ app.post('/api/:endpoint', (request, response) => {
data: {
direction: 'outgoing',
id,
otherNumber: request.body.phoneNumber,
startedAt: Date.now(),
otherNumber: phoneNumber,
startedAt,
state: 'ringing',
},
})
return
}
if (endpoint === 'calls:block') {
const phoneNumber = String(request.body.phoneNumber ?? '').replace(
/\D/g,
'',
)
if (!phoneNumber) {
response.json({ success: false, error: 'invalid_number' })
return
}
blockedCallNumbers.add(phoneNumber)
response.json({ success: true, data: { blocked: true, phoneNumber } })
return
}
if (
endpoint === 'calls:answer' ||
endpoint === 'calls:decline' ||
+3
View File
@@ -15,6 +15,7 @@ Config.Command = "phone"
Config.Phone = {
Item = "phone",
Unique = true, -- true: data follows each phone item; false: one persistent phone per character
DevelopmentCommand = true,
DeviceName = "iFruit Phone",
}
@@ -44,6 +45,7 @@ Config.Security = {
}
Config.Sim = {
Enabled = true, -- false: devices without a SIM receive a persistent random number automatically
RegisteredItem = "sky_phone_sim_registered",
AnonymousItem = "sky_phone_sim_anonymous",
NumberLength = 10,
@@ -55,6 +57,7 @@ Config.Calls = {
VoiceProvider = "pma",
RingSeconds = 30,
ContactNameMaxLength = 80,
ContactNotesMaxLength = 500,
RecentPageSize = 100,
}
+13 -2
View File
@@ -21,6 +21,7 @@ Locales["en"] = {
phone_stacked = "Phones cannot be stacked.",
invalid_imei = "This phone has invalid IMEI metadata.",
metadata_unsupported = "The configured inventory cannot store unique phone metadata.",
player_unavailable = "Your character identity is not available.",
sim_slot_missing = "The used SIM card could not be identified.",
sim_stacked = "SIM cards cannot be stacked.",
invalid_sim = "This SIM card has invalid metadata.",
@@ -685,9 +686,17 @@ Locales["en"] = {
phone = {
name = "Phone", recents = "Recents", contacts = "Contacts", keypad = "Keypad",
noSim = "No SIM", noSimBody = "Insert a SIM card in Settings to make calls.",
noRecents = "No Recent Calls", noContacts = "No Contacts", searchContacts = "Search Contacts",
addContact = "New Contact", editContact = "Edit Contact", contactName = "Name", phoneNumber = "Phone Number",
noRecents = "No Recent Calls", noContacts = "No Contacts", searchContacts = "Search Contacts", favorites = "Favorites",
addContact = "New Contact", newContact = "New", editContact = "Edit Contact", contactName = "Name", firstName = "First Name", lastName = "Last Name", companyOrGroup = "Company or Group", phoneNumber = "Phone Number",
officialContact = "Official company contact",
choosePhoto = "Choose Contact Photo", chooseGallery = "Gallery", takePhoto = "Camera", removePhoto = "Remove Photo",
message = "Message", video = "Video", mail = "Mail", defaultLabel = "Default", privateLabel = "Private",
contactCard = "Contact Card", removeContact = "Remove Contact", returnToCall = "Return to Call", mobile = "Mobile", messagesProfile = "Messages", notes = "Notes",
sendMessage = "Send Message", shareContact = "Share Contact", addFavorite = "Add to Favorites", removeFavorite = "Remove from Favorites",
addEmergency = "Add to Emergency Contacts", blockContact = "Block Contact",
block = "Block", blockCaller = "Block Caller", blockCallerTitle = "Block this caller?",
blockCallerBody = "{number} will no longer be able to call this SIM.",
faceTime = "FaceTime", hideKeypad = "Hide Keypad", more = "More", mute = "Mute", speaker = "Speaker", viewContact = "View Contact",
call = "Call", calling = "calling...", incoming = "Incoming Call", incomingDirection = "Incoming", outgoingDirection = "Outgoing", connected = "Connected",
missed = "Missed", declined = "Declined", busy = "Busy", unavailable = "Unavailable",
noAnswer = "No Answer", cancelled = "Cancelled", disconnected = "Disconnected", sim_removed = "SIM Removed", completed = "Call Ended",
@@ -702,6 +711,8 @@ Locales["en"] = {
self_call = "You cannot call your own number.", busy = "The line is busy.",
company_unavailable = "This company service line is unavailable.",
readonly_contact = "Official company contacts cannot be changed.",
message_unavailable = "The conversation could not be opened.", contact_remove_failed = "The contact could not be removed.", contact_favorite_failed = "The favorite could not be updated.",
blocked = "This number is blocked.", recipient_not_found = "This number is not known.",
rate_limited = "Too many calls. Try again in a minute.", voice_unavailable = "The configured phone voice service is unavailable.",
inventory_full = "There is no room for the ejected SIM card.", request_failed = "The phone request failed.",
operation_in_progress = "Another phone operation is already in progress.", sim_request_expired = "The SIM selection expired. Use the SIM card again.",
@@ -9,11 +9,26 @@ end
local ESX = exports["es_extended"]:getSharedObject()
local metadata_key = "sky_phone_inventory"
ESX.AddItems({
local inventory_items = {
{ name = Config.Phone.Item, label = Config.Phone.DeviceName, weight = 100, rare = false, canRemove = true },
{ name = Config.Sim.RegisteredItem, label = "Registered SIM", weight = 5, rare = false, canRemove = true },
{ name = Config.Sim.AnonymousItem, label = "Anonymous SIM", weight = 5, rare = false, canRemove = true },
})
}
if Config.Sim.Enabled ~= false then
inventory_items[#inventory_items + 1] = {
name = Config.Sim.RegisteredItem,
label = "Registered SIM",
weight = 5,
rare = false,
canRemove = true,
}
inventory_items[#inventory_items + 1] = {
name = Config.Sim.AnonymousItem,
label = "Anonymous SIM",
weight = 5,
rare = false,
canRemove = true,
}
end
ESX.AddItems(inventory_items)
local function get_player(source)
return ESX.GetPlayerFromId(source)
+2
View File
@@ -194,11 +194,13 @@ local server_callbacks = {
"contacts:list",
"contacts:save",
"contacts:delete",
"contacts:favorite",
"calls:recents",
"calls:dial",
"calls:answer",
"calls:decline",
"calls:hangup",
"calls:block",
"banking:overview",
"banking:transfer",
"billing:overview",
+127 -13
View File
@@ -333,8 +333,8 @@ function SkyPhoneCalls.CopyCloudToDevice(account_id, imei)
{
query = [[
INSERT INTO `sky_phone_contacts`
(`id`, `contact_id`, `device_imei`, `name`, `phone_number`, `created_at`, `updated_at`)
SELECT UUID(), `contact_id`, ?, `name`, `phone_number`, `created_at`, `updated_at`
(`id`, `contact_id`, `device_imei`, `name`, `notes`, `organization`, `phone_number`, `avatar_media_id`, `favorite`, `created_at`, `updated_at`)
SELECT UUID(), `contact_id`, ?, `name`, `notes`, `organization`, `phone_number`, NULL, `favorite`, `created_at`, `updated_at`
FROM `sky_phone_contacts` WHERE `account_id` = ?
]],
params = { imei, account_id },
@@ -358,7 +358,9 @@ Bridge.Callbacks.Register("sky_phone:contacts:list", function(source)
end
local condition, params = scope_condition(scope)
local rows = Bridge.Database.Query(([[
SELECT `contact_id` AS `id`, `name`, `phone_number`, `created_at`, `updated_at`
SELECT `contact_id` AS `id`, `name`, `notes`, `organization`, `phone_number`, `avatar_media_id`, `favorite`,
(SELECT media.`url` FROM `sky_phone_media` media WHERE media.`id` = `avatar_media_id`) AS `avatar_url`,
`created_at`, `updated_at`
FROM `sky_phone_contacts` WHERE %s ORDER BY LOWER(`name`), `phone_number`
]]):format(condition), params)
local system_contacts = SkyPhoneCompanies.GetSystemContacts()
@@ -393,8 +395,11 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
return error_response
end
local name = trim(data.name)
local notes = trim(data.notes) or ""
local organization = trim(data.organization) or ""
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not name or name == "" or #name > Config.Calls.ContactNameMaxLength or not number then
local avatar_media_id = tonumber(data.avatarMediaId) or 0
if not name or name == "" or #name > Config.Calls.ContactNameMaxLength or #notes > Config.Calls.ContactNotesMaxLength or #organization > Config.Calls.ContactNameMaxLength or not number then
return { success = false, error = "invalid_contact" }
end
if (type(data.id) == "string" and data.id:sub(1, 8) == "company:")
@@ -407,6 +412,17 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
)
return { success = false, error = "readonly_contact" }
end
if avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id) then
return { success = false, error = "invalid_contact" }
end
local avatar_url
if avatar_media_id > 0 then
local media_error
avatar_url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, tostring(avatar_media_id), "photo")
if not avatar_url then
return { success = false, error = media_error }
end
end
local condition, condition_params = scope_condition(scope)
local id = type(data.id) == "string" and data.id or uuid()
if data.id then
@@ -418,24 +434,63 @@ Bridge.Callbacks.Register("sky_phone:contacts:save", function(source, data)
if not owned[1] then
return { success = false, error = "contact_not_found" }
end
local params = { name, number, id }
local params = { name, notes, organization, number, avatar_media_id, id }
for _, value in ipairs(condition_params) do
params[#params + 1] = value
end
Bridge.Database.Query(([[
UPDATE `sky_phone_contacts` SET `name` = ?, `phone_number` = ?
UPDATE `sky_phone_contacts` SET `name` = ?, `notes` = NULLIF(?, ''), `organization` = NULLIF(?, ''), `phone_number` = ?, `avatar_media_id` = NULLIF(?, 0)
WHERE `contact_id` = ? AND %s
]]):format(condition), params)
else
Bridge.Database.Query([[
INSERT INTO `sky_phone_contacts` (`id`, `contact_id`, `account_id`, `device_imei`, `name`, `phone_number`)
VALUES (?, ?, ?, ?, ?, ?)
]], { uuid(), id, scope.account_id, scope.device_imei, name, number })
INSERT INTO `sky_phone_contacts` (`id`, `contact_id`, `account_id`, `device_imei`, `name`, `notes`, `organization`, `phone_number`, `avatar_media_id`)
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, NULLIF(?, 0))
]], { uuid(), id, scope.account_id, scope.device_imei, name, notes, organization, number, avatar_media_id })
end
if scope.account_id then
SkyPhone.NotifyAccount(scope.account_id, "sky_phone:contacts:changed", {})
end
return { success = true, data = { id = id, name = name, phone_number = number } }
return {
success = true,
data = {
id = id,
name = name,
notes = notes ~= "" and notes or nil,
organization = organization ~= "" and organization or nil,
phone_number = number,
avatar_media_id = avatar_media_id > 0 and avatar_media_id or nil,
avatar_url = avatar_url,
},
}
end)
Bridge.Callbacks.Register("sky_phone:contacts:favorite", function(source, data)
if not SkyPhone.AllowOperation(source, "contact_favorite", 60, 60) or type(data) ~= "table" or type(data.id) ~= "string" or type(data.favorite) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
local scope, error_response = current_scope(source)
if not scope then
return error_response
end
local condition, condition_params = scope_condition(scope)
local owned_params = { data.id }
for _, value in ipairs(condition_params) do
owned_params[#owned_params + 1] = value
end
local owned = Bridge.Database.Query(("SELECT `id` FROM `sky_phone_contacts` WHERE `contact_id` = ? AND %s LIMIT 1"):format(condition), owned_params)
if not owned[1] then
return { success = false, error = "contact_not_found" }
end
local params = { data.favorite and 1 or 0, data.id }
for _, value in ipairs(condition_params) do
params[#params + 1] = value
end
Bridge.Database.Query(("UPDATE `sky_phone_contacts` SET `favorite` = ? WHERE `contact_id` = ? AND %s"):format(condition), params)
if scope.account_id then
SkyPhone.NotifyAccount(scope.account_id, "sky_phone:contacts:changed", {})
end
return { success = true, data = { id = data.id, favorite = data.favorite } }
end)
Bridge.Callbacks.Register("sky_phone:contacts:delete", function(source, data)
@@ -728,10 +783,19 @@ handle_no_answer = function(call)
end
end
local function lock_direct_target(caller_source, target)
local function lock_direct_target(caller_source, target, caller_sim_id)
if not target or not target.imei then
return nil, "unavailable"
end
if caller_sim_id then
local blocks = Bridge.Database.Query([[
SELECT 1 FROM `sky_phone_call_blocks`
WHERE `blocker_sim_id` = ? AND `blocked_sim_id` = ? LIMIT 1
]], { target.id, caller_sim_id })
if blocks[1] then
return nil, "declined"
end
end
local callee_source = find_device_holder(target.imei)
if not callee_source or callee_source == caller_source then
return nil, "unavailable"
@@ -805,7 +869,7 @@ function SkyPhoneCalls.StartCompanyCall(source, company_id, customer_number)
WHERE s.`phone_number` = ? LIMIT 1
]], { number })
local target = targets[1]
local callee_source, target_status = lock_direct_target(source, target)
local callee_source, target_status = lock_direct_target(source, target, scope.device.sim_id)
if not callee_source then
local terminal = create_terminal_call(scope, number, target, target_status, service_line.number)
dialing_by_sim[scope.device.sim_id] = nil
@@ -928,7 +992,12 @@ Bridge.Callbacks.Register("sky_phone:calls:dial", function(source, data)
WHERE s.`phone_number` = ? LIMIT 1
]], { number })
local target = targets[1]
local callee_source, target_status = lock_direct_target(source, target)
if not target then
dialing_by_sim[scope.device.sim_id] = nil
dial_locks[source] = nil
return { success = false, error = "recipient_not_found" }
end
local callee_source, target_status = lock_direct_target(source, target, scope.device.sim_id)
if not callee_source then
local terminal = create_terminal_call(scope, number, target, target_status)
dialing_by_sim[scope.device.sim_id] = nil
@@ -1169,6 +1238,51 @@ Bridge.Callbacks.Register("sky_phone:calls:hangup", function(source, data)
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:calls:block", function(source, data)
if not SkyPhone.AllowOperation(source, "call_block", 10, 60) then
return { success = false, error = "rate_limited" }
end
local scope, error_response = current_scope(source)
if not scope then
return error_response
end
if not scope.device.sim_id then
return { success = false, error = "no_sim" }
end
local number = type(data) == "table" and SkyPhoneSimNumber.Normalize(
data.phoneNumber,
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
) or nil
if not number then
return { success = false, error = "invalid_number" }
end
if number == scope.device.phone_number then
return { success = false, error = "self_call" }
end
local rows = Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_sims` WHERE `phone_number` = ? LIMIT 1",
{ number }
)
if not rows[1] then
return { success = false, error = "recipient_not_found" }
end
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_call_blocks` (`blocker_sim_id`, `blocked_sim_id`)
VALUES (?, ?)
]], { scope.device.sim_id, rows[1].id })
local call_id = active_by_source[source]
local call = call_id and calls[call_id] or nil
if call then
local other_number = source == call.caller_source and call.callee_number or call.caller_number
if other_number == number then
finish_call(call, call.answered_at and "completed" or "declined")
end
end
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:payphone:hangup", function(source, data)
local call_id = active_by_source[source]
local call = call_id and calls[call_id] or nil
+40
View File
@@ -122,6 +122,7 @@ local schema = {
{ name = "contact_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "phone_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "sim_type", type = "ENUM('registered', 'anonymous') NOT NULL" },
{ name = "is_virtual", type = "TINYINT(1) NOT NULL DEFAULT 0" },
{ name = "owner_identifier", type = "VARCHAR(80) NULL" },
{ name = "owner_firstname", type = "VARCHAR(80) NULL" },
{ name = "owner_lastname", type = "VARCHAR(80) NULL" },
@@ -173,6 +174,39 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_character_devices",
columns = {
{
name = "owner_identifier",
type = "VARCHAR(80) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{
name = "device_imei",
type = "CHAR(15) NOT NULL",
characterSet = "ascii",
collation = "ascii_bin",
},
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{
name = "updated_at",
type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP",
},
},
primaryKey = "owner_identifier",
uniqueKeys = {
{ name = "uniq_sky_phone_character_devices_device", columns = "(`device_imei`)" },
},
foreignKeys = {
{
column = "device_imei",
references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE",
},
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_device_data",
columns = {
@@ -370,7 +404,11 @@ local schema = {
{ name = "account_id", type = "BIGINT UNSIGNED NULL" },
{ name = "device_imei", type = "CHAR(15) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "name", type = "VARCHAR(80) NOT NULL" },
{ name = "notes", type = "VARCHAR(500) NULL" },
{ name = "organization", type = "VARCHAR(80) NULL" },
{ name = "phone_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
{ name = "favorite", type = "TINYINT(1) NOT NULL DEFAULT 0" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
@@ -378,10 +416,12 @@ local schema = {
indexes = {
{ name = "idx_sky_phone_contacts_account", columns = "(`account_id`, `name`)" },
{ name = "idx_sky_phone_contacts_device", columns = "(`device_imei`, `name`)" },
{ name = "idx_sky_phone_contacts_avatar", columns = "(`avatar_media_id`)" },
},
foreignKeys = {
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
{ column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
{ column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
+8 -4
View File
@@ -433,10 +433,14 @@ Bridge.Callbacks.Register("sky_phone:messages:send", function(source, data)
else
return { success = false, error = "invalid_request" }
end
local recipients = Bridge.Database.Query(
"SELECT `id`, `phone_number` FROM `sky_phone_sims` WHERE `phone_number` = ? LIMIT 1",
{ number }
)
local recipients = Bridge.Database.Query([[
SELECT s.`id`, s.`phone_number`
FROM `sky_phone_sims` s
LEFT JOIN `sky_phone_devices` d ON d.`sim_id` = s.`id`
WHERE s.`phone_number` = ?
AND (s.`is_virtual` = 0 OR d.`imei` IS NOT NULL)
LIMIT 1
]], { number })
local recipient = recipients[1]
if not recipient then
return { success = false, error = "recipient_not_found" }
+178 -18
View File
@@ -3,9 +3,12 @@ Bridge.Debug("debug", "[sky_phone] Server initialization started after database
SkyPhone = {}
local unique_phones = Config.Phone.Unique ~= false
local sim_cards_enabled = Config.Sim.Enabled ~= false
local sessions = {}
local auth_attempts = {}
local operation_attempts = {}
local character_device_cache = {}
local max_device_data_bytes = 100000
local passcode_pepper = GetConvar(Config.Security.PasscodePepperConvar, "")
if passcode_pepper == "" then
@@ -64,9 +67,130 @@ local function reserve_imei()
return imei
end
local function character_identifier(source)
local identifier = Bridge.Framework.GetIdentifier(source)
if identifier == nil then
return nil
end
identifier = tostring(identifier)
if identifier == "" or #identifier > 80 then
Bridge.Debug(
"warn",
"[sky_phone] Could not resolve a valid character identifier for source %s.",
tostring(source)
)
return nil
end
return identifier
end
local function load_character_device(source, identifier)
identifier = identifier or character_identifier(source)
if not identifier then
return nil, "player_unavailable"
end
local cached = character_device_cache[source]
if cached and cached.identifier == identifier then
return cached.imei, nil, identifier
end
local rows = Bridge.Database.Query([[
SELECT `device_imei`
FROM `sky_phone_character_devices`
WHERE `owner_identifier` = ?
LIMIT 1
]], { identifier })
local imei = rows[1] and rows[1].device_imei or nil
if imei then
character_device_cache[source] = { identifier = identifier, imei = imei }
else
character_device_cache[source] = nil
end
return imei, nil, identifier
end
local function cache_character_device(source, identifier, imei)
character_device_cache[source] = { identifier = identifier, imei = imei }
return imei
end
local function map_character_device(source, slot)
local mapped_imei, error_code, identifier = load_character_device(source)
if error_code or mapped_imei then
return mapped_imei, error_code
end
-- On the first switch from unique to shared phones, adopt the currently used
-- legacy device when it has not already been claimed by another character.
local legacy_imei = slot.metadata and slot.metadata.imei or nil
if SkyPhoneImei.IsValid(legacy_imei) then
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_devices` (`imei`, `device_name`)
VALUES (?, ?)
]], { legacy_imei, Config.Phone.DeviceName })
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_character_devices` (`owner_identifier`, `device_imei`)
VALUES (?, ?)
]], { identifier, legacy_imei })
if affected_rows(result) == 1 then
return cache_character_device(source, identifier, legacy_imei)
end
mapped_imei = load_character_device(source, identifier)
if mapped_imei then
return mapped_imei
end
end
local imei = reserve_imei()
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_character_devices` (`owner_identifier`, `device_imei`)
VALUES (?, ?)
]], { identifier, imei })
if affected_rows(result) == 1 then
return cache_character_device(source, identifier, imei)
end
-- Another simultaneous open may have created the mapping first. Keep its
-- winner and remove only the fresh, unmapped device reserved by this call.
mapped_imei = load_character_device(source, identifier)
Bridge.Database.Query([[
DELETE d FROM `sky_phone_devices` d
LEFT JOIN `sky_phone_character_devices` c ON c.`device_imei` = d.`imei`
WHERE d.`imei` = ? AND c.`device_imei` IS NULL
]], { imei })
if mapped_imei then
return mapped_imei
end
Bridge.Debug(
"error",
"[sky_phone] Could not create a shared device mapping for source %s.",
tostring(source)
)
return nil, "request_failed"
end
local function find_device_slots(source, imei)
local matches = {}
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)
if not unique_phones then
if not slots[1] then
return matches
end
local mapped_imei = load_character_device(source)
if mapped_imei ~= imei then
return matches
end
for _, item in ipairs(slots) do
matches[#matches + 1] = item
end
return matches
end
for _, item in ipairs(slots) do
if item.metadata and item.metadata.imei == imei then
matches[#matches + 1] = item
end
@@ -134,7 +258,7 @@ local function resolve_used_slot(source, used_item)
{ always = true }
)
end
if #slots == 1 then
if #slots == 1 or (not unique_phones and #slots > 0) then
return slots[1]
end
@@ -159,6 +283,13 @@ local function ensure_device(source, slot)
tostring(Bridge.Inventory.GetResourceName()),
{ always = true }
)
if not unique_phones then
if amount < 1 then
return nil, "phone_slot_missing"
end
return map_character_device(source, slot)
end
if amount ~= 1 then
Bridge.Debug("warn", "[sky_phone] Phone item in slot %s is stacked for source %s.", tostring(slot.slot), tostring(source))
return nil, "phone_stacked"
@@ -217,7 +348,7 @@ end
local function load_device(imei)
local rows = Bridge.Database.Query([[
SELECT d.`imei`, d.`device_name`, d.`account_id`, d.`sim_id`, d.`created_at`, d.`updated_at`,
a.`email`, s.`phone_number`, s.`sim_type`, s.`registered_at`
a.`email`, s.`phone_number`, s.`sim_type`, s.`registered_at`, s.`is_virtual` AS `sim_is_virtual`
FROM `sky_phone_devices` d
LEFT JOIN `sky_phone_accounts` a ON a.`id` = d.`account_id`
LEFT JOIN `sky_phone_sims` s ON s.`id` = d.`sim_id`
@@ -370,6 +501,7 @@ local function bootstrap(source, security, security_loaded)
sim = device.sim_id and {
id = device.sim_id,
number = device.phone_number,
removable = sim_cards_enabled and tonumber(device.sim_is_virtual) ~= 1,
type = device.sim_type,
registered = device.registered_at ~= nil,
} or nil,
@@ -648,24 +780,38 @@ function SkyPhone.NotifyAccountDevices(account_id, event_name, data)
devices[row.imei] = row
end
local function notify_device(source, device)
local payload = {}
for key, value in pairs(data) do
payload[key] = value
end
payload.device = {
imei = device.imei,
name = device.device_name,
settings = device.settings,
}
TriggerClientEvent(event_name, source, payload)
end
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
local source = tonumber(player_source) or player_source
local notified_devices = {}
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
local imei = item.metadata and item.metadata.imei
local device = imei and devices[imei]
if device and not notified_devices[imei] then
local payload = {}
for key, value in pairs(data) do
payload[key] = value
if not unique_phones then
if Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)[1] then
local imei = load_character_device(source)
local device = imei and devices[imei] or nil
if device then
notify_device(source, device)
end
end
else
local notified_devices = {}
for _, item in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
local imei = item.metadata and item.metadata.imei
local device = imei and devices[imei]
if device and not notified_devices[imei] then
notified_devices[imei] = true
notify_device(source, device)
end
payload.device = {
imei = device.imei,
name = device.device_name,
settings = device.settings,
}
notified_devices[imei] = true
TriggerClientEvent(event_name, source, payload)
end
end
end
@@ -721,6 +867,18 @@ local function open_phone(source, used_item)
TriggerClientEvent("sky_phone:device:error", source, error_code)
return false
end
local prepared, prepare_error = SkyPhoneSim.PrepareDevice(source, slot, imei)
if not prepared then
Bridge.Debug(
"error",
"[sky_phone] Phone preparation failed for source %s IMEI %s: %s.",
tostring(source),
imei,
tostring(prepare_error)
)
TriggerClientEvent("sky_phone:device:error", source, prepare_error or "request_failed")
return false
end
local security = load_device_security(imei)
if sessions[source] and sessions[source].imei ~= imei then
@@ -1157,6 +1315,7 @@ AddEventHandler("playerDropped", function()
sessions[source] = nil
auth_attempts[source] = nil
operation_attempts[source] = nil
character_device_cache[source] = nil
end)
AddEventHandler("onResourceStop", function(resource_name)
@@ -1164,6 +1323,7 @@ AddEventHandler("onResourceStop", function(resource_name)
sessions = {}
auth_attempts = {}
operation_attempts = {}
character_device_cache = {}
end
end)
end)
+160 -29
View File
@@ -2,6 +2,8 @@ Bridge.Database.AfterMigration("sky_phone", function()
SkyPhoneSim = {}
local unique_phones = Config.Phone.Unique ~= false
local sim_cards_enabled = Config.Sim.Enabled ~= false
local pending_insertions = {}
local operation_locks = {}
local sim_types = {
@@ -24,7 +26,7 @@ local function uuid()
return rows[1].id
end
local function reserve_sim(sim_type)
local function reserve_sim(sim_type, is_virtual)
local sim_id
local number = SkyPhoneSimNumber.Reserve(uuid, function(candidate)
if SkyPhoneCompanies.IsServiceNumber(candidate) then
@@ -32,9 +34,9 @@ local function reserve_sim(sim_type)
end
sim_id = uuid()
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_sims` (`id`, `phone_number`, `sim_type`)
VALUES (?, ?, ?)
]], { sim_id, candidate, sim_type })
INSERT IGNORE INTO `sky_phone_sims` (`id`, `phone_number`, `sim_type`, `is_virtual`)
VALUES (?, ?, ?, ?)
]], { sim_id, candidate, sim_type, is_virtual and 1 or 0 })
return affected_rows(result) == 1
end, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number then
@@ -65,6 +67,91 @@ local function sim_metadata(sim)
return metadata
end
local function set_phone_sim_metadata(source, phone_slot, sim)
if not unique_phones then
return true
end
if not phone_slot then
return false
end
local metadata = phone_slot.metadata or {}
metadata.sim_id = sim and sim.id or nil
metadata.phone_number = sim and sim.phone_number or nil
metadata.formatted_number = sim and SkyPhoneSimNumber.Format(
sim.phone_number,
Config.Sim.NumberGroups,
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
) or nil
return Bridge.Inventory.SetSlotMetadata(source, phone_slot.slot, metadata)
end
local function prepare_device(source, phone_slot, imei)
local device = SkyPhone.LoadDevice(imei)
if not device then
return false, "request_failed"
end
local sim = device.sim_id and load_sim(device.sim_id) or nil
if sim_cards_enabled then
if sim and tonumber(sim.is_virtual) == 1 then
local result = Bridge.Database.Query([[
UPDATE `sky_phone_devices`
SET `sim_id` = NULL
WHERE `imei` = ? AND `sim_id` = ?
]], { imei, sim.id })
if affected_rows(result) ~= 1 then
return false, "request_failed"
end
sim = nil
end
if not set_phone_sim_metadata(source, phone_slot, sim) then
return false, "metadata_unsupported"
end
return true
end
if sim then
if not set_phone_sim_metadata(source, phone_slot, sim) then
return false, "metadata_unsupported"
end
return true
end
local automatic_sim = reserve_sim("anonymous", true)
local result = Bridge.Database.Query([[
UPDATE `sky_phone_devices`
SET `sim_id` = ?
WHERE `imei` = ? AND `sim_id` IS NULL
]], { automatic_sim.id, imei })
if affected_rows(result) ~= 1 then
Bridge.Database.Query("DELETE FROM `sky_phone_sims` WHERE `id` = ?", { automatic_sim.id })
device = SkyPhone.LoadDevice(imei)
sim = device and device.sim_id and load_sim(device.sim_id) or nil
if not sim then
return false, "request_failed"
end
if not set_phone_sim_metadata(source, phone_slot, sim) then
return false, "metadata_unsupported"
end
return true
end
if not set_phone_sim_metadata(source, phone_slot, automatic_sim) then
Bridge.Database.Query(
"UPDATE `sky_phone_devices` SET `sim_id` = NULL WHERE `imei` = ? AND `sim_id` = ?",
{ imei, automatic_sim.id }
)
Bridge.Database.Query("DELETE FROM `sky_phone_sims` WHERE `id` = ?", { automatic_sim.id })
return false, "metadata_unsupported"
end
return true
end
SkyPhoneSim.PrepareDevice = prepare_device
local function resolve_used_sim(source, used_item, item_name)
local slot_id = used_item and (used_item.slot or used_item.id)
local slot = slot_id and Bridge.Inventory.GetSlot(source, slot_id) or nil
@@ -85,7 +172,12 @@ local function ensure_sim(source, slot, sim_type)
end
local metadata = slot.metadata or {}
local sim = metadata.sim_id and load_sim(metadata.sim_id) or nil
if metadata.sim_id and (not sim or sim.sim_type ~= sim_type or sim.phone_number ~= metadata.phone_number) then
if metadata.sim_id and (
not sim
or tonumber(sim.is_virtual) == 1
or sim.sim_type ~= sim_type
or sim.phone_number ~= metadata.phone_number
) then
return nil, "invalid_sim"
end
if sim and SkyPhoneCompanies.IsServiceNumber(sim.phone_number) then
@@ -109,30 +201,46 @@ end
local function list_phone_choices(source)
local choices = {}
local seen = {}
local choice_error
for _, slot in ipairs(Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)) do
local imei = SkyPhone.EnsureDevice(source, slot)
if imei then
local device = SkyPhone.LoadDevice(imei)
choices[#choices + 1] = {
imei = imei,
name = device.device_name,
occupied = device.sim_id ~= nil,
number = device.phone_number,
}
local imei, error_code = SkyPhone.EnsureDevice(source, slot)
if imei and not seen[imei] then
local prepared, prepare_error = prepare_device(source, slot, imei)
if prepared then
local device = SkyPhone.LoadDevice(imei)
choices[#choices + 1] = {
imei = imei,
name = device.device_name,
occupied = device.sim_id ~= nil,
number = device.phone_number,
}
seen[imei] = true
else
Bridge.Debug(
"error",
"[sky_phone] Could not prepare SIM target %s for source %s: %s.",
imei,
tostring(source),
tostring(prepare_error)
)
choice_error = choice_error or prepare_error
end
elseif not imei then
choice_error = choice_error or error_code
end
end
return choices
return choices, choice_error
end
local function rollback_phone_metadata(source, phone_slot, old_sim)
local metadata = phone_slot.metadata or {}
metadata.sim_id = old_sim and old_sim.id or nil
metadata.phone_number = old_sim and old_sim.phone_number or nil
metadata.formatted_number = old_sim and SkyPhoneSimNumber.Format(old_sim.phone_number, Config.Sim.NumberGroups, Config.Sim.NumberLength, Config.Sim.NumberPrefix) or nil
Bridge.Inventory.SetSlotMetadata(source, phone_slot.slot, metadata)
set_phone_sim_metadata(source, phone_slot, old_sim)
end
local function insert_sim(source, phone_imei, confirmed)
if not sim_cards_enabled then
return { success = false, error = "disabled" }
end
if operation_locks[source] then
return { success = false, error = "operation_in_progress" }
end
@@ -161,6 +269,10 @@ local function insert_sim(source, phone_imei, confirmed)
return { success = false, error = "phone_not_owned" }
end
local phone_slot = phone_matches[1]
local prepared, prepare_error = prepare_device(source, phone_slot, phone_imei)
if not prepared then
return { success = false, error = prepare_error }
end
local device = SkyPhone.LoadDevice(phone_imei)
local old_sim = device.sim_id and load_sim(device.sim_id) or nil
if old_sim and not confirmed then
@@ -168,11 +280,7 @@ local function insert_sim(source, phone_imei, confirmed)
end
operation_locks[source] = true
local phone_metadata = phone_slot.metadata or {}
phone_metadata.sim_id = pending.sim.id
phone_metadata.phone_number = pending.sim.phone_number
phone_metadata.formatted_number = SkyPhoneSimNumber.Format(pending.sim.phone_number, Config.Sim.NumberGroups, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not Bridge.Inventory.SetSlotMetadata(source, phone_slot.slot, phone_metadata) then
if not set_phone_sim_metadata(source, phone_slot, pending.sim) then
operation_locks[source] = nil
return { success = false, error = "metadata_unsupported" }
end
@@ -234,6 +342,9 @@ local function insert_sim(source, phone_imei, confirmed)
end
local function use_sim(source, used_item)
if not sim_cards_enabled then
return false
end
local item_name = used_item and used_item.name
local sim_type = item_name and sim_types[item_name]
if not sim_type then
@@ -257,10 +368,10 @@ local function use_sim(source, used_item)
TriggerClientEvent("sky_phone:device:error", source, error_code)
return false
end
local choices = list_phone_choices(source)
local choices, choice_error = list_phone_choices(source)
if #choices == 0 then
operation_locks[source] = nil
TriggerClientEvent("sky_phone:device:error", source, "phone_required")
TriggerClientEvent("sky_phone:device:error", source, choice_error or "phone_required")
return false
end
pending_insertions[source] = {
@@ -280,10 +391,15 @@ local function use_sim(source, used_item)
return true
end
-- Register the guarded callbacks in both modes so a resource-only restart can
-- replace registrations left behind in framework-owned usable-item tables.
Bridge.Inventory.RegisterUsableItem(Config.Sim.RegisteredItem, use_sim)
Bridge.Inventory.RegisterUsableItem(Config.Sim.AnonymousItem, use_sim)
Bridge.Callbacks.Register("sky_phone:sim:insert", function(source, data)
if not sim_cards_enabled then
return { success = false, error = "disabled" }
end
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
return { success = false, error = "invalid_request" }
end
@@ -291,6 +407,9 @@ Bridge.Callbacks.Register("sky_phone:sim:insert", function(source, data)
end)
Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
if not sim_cards_enabled then
return { success = false, error = "disabled" }
end
if operation_locks[source] then
return { success = false, error = "operation_in_progress" }
end
@@ -300,7 +419,7 @@ Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
end
local device = SkyPhone.LoadDevice(session.imei)
local sim = device and device.sim_id and load_sim(device.sim_id) or nil
if not sim then
if not sim or tonumber(sim.is_virtual) == 1 then
return { success = false, error = "no_sim" }
end
local phone_slot = Bridge.Inventory.GetSlot(source, session.slot)
@@ -311,7 +430,10 @@ Bridge.Callbacks.Register("sky_phone:sim:eject", function(source)
end
operation_locks[source] = true
rollback_phone_metadata(source, phone_slot, nil)
if not set_phone_sim_metadata(source, phone_slot, nil) then
operation_locks[source] = nil
return { success = false, error = "metadata_unsupported" }
end
if not Bridge.Inventory.AddItem(source, item_name, 1, nil, metadata) then
rollback_phone_metadata(source, phone_slot, sim)
operation_locks[source] = nil
@@ -337,4 +459,13 @@ AddEventHandler("playerDropped", function()
pending_insertions[source] = nil
operation_locks[source] = nil
end)
if sim_cards_enabled then
Bridge.Database.Query([[
UPDATE `sky_phone_devices` d
INNER JOIN `sky_phone_sims` s ON s.`id` = d.`sim_id`
SET d.`sim_id` = NULL
WHERE s.`is_virtual` = 1
]], {})
end
end)
+28 -1
View File
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_sims` (
`contact_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
`phone_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`sim_type` ENUM('registered', 'anonymous') NOT NULL,
`is_virtual` TINYINT(1) NOT NULL DEFAULT 0,
`owner_identifier` VARCHAR(80) NULL,
`owner_firstname` VARCHAR(80) NULL,
`owner_lastname` VARCHAR(80) NULL,
@@ -80,6 +81,16 @@ CREATE TABLE IF NOT EXISTS `sky_phone_devices` (
FOREIGN KEY (`sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_character_devices` (
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`owner_identifier`),
UNIQUE KEY `uniq_sky_phone_character_devices_device` (`device_imei`),
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_device_data` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
@@ -156,7 +167,11 @@ CREATE TABLE IF NOT EXISTS `sky_phone_contacts` (
`account_id` BIGINT UNSIGNED NULL,
`device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NULL,
`name` VARCHAR(80) NOT NULL,
`notes` VARCHAR(500) NULL,
`organization` VARCHAR(80) NULL,
`phone_number` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`avatar_media_id` BIGINT UNSIGNED NULL,
`favorite` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
@@ -164,8 +179,10 @@ CREATE TABLE IF NOT EXISTS `sky_phone_contacts` (
UNIQUE KEY `uniq_sky_phone_contacts_device_contact` (`device_imei`, `contact_id`),
KEY `idx_sky_phone_contacts_account` (`account_id`, `name`),
KEY `idx_sky_phone_contacts_device` (`device_imei`, `name`),
KEY `idx_sky_phone_contacts_avatar` (`avatar_media_id`),
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE
FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE,
FOREIGN KEY (`avatar_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_calls` (
@@ -186,6 +203,16 @@ CREATE TABLE IF NOT EXISTS `sky_phone_calls` (
FOREIGN KEY (`callee_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_call_blocks` (
`blocker_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`blocked_sim_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`blocker_sim_id`, `blocked_sim_id`),
KEY `idx_sky_phone_call_blocks_blocked` (`blocked_sim_id`),
FOREIGN KEY (`blocker_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`blocked_sim_id`) REFERENCES `sky_phone_sims` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_call_entries` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`call_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,