ENH - make phone identity and SIM cards configurable

This commit is contained in:
Dominik
2026-08-11 12:25:51 +02:00
parent 72e5f91e02
commit ebac6e5509
13 changed files with 484 additions and 67 deletions
+61 -9
View File
@@ -78,16 +78,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
@@ -108,7 +160,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
@@ -127,13 +179,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"] = {
@@ -159,7 +211,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.
+1
View File
@@ -335,6 +335,7 @@ async function hydrateDevelopmentPhone(): Promise<void> {
sim: {
id: 'development-sim',
number: '5551234567',
removable: true,
registered: true,
type: 'registered',
},
+1
View File
@@ -3,6 +3,7 @@ export type SimType = 'registered' | 'anonymous'
export type PhoneSim = {
id: string
number: string
removable: boolean
registered: boolean
type: SimType
}
+7 -3
View File
@@ -1331,7 +1331,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(
@@ -1342,7 +1342,7 @@ onBeforeUnmount(() => {
"
/>
<k-list-button
v-if="phone.device?.sim"
v-if="phone.device?.sim?.removable"
@click="simEjectOpened = true"
>
{{ phone.t('Apps.settings.ejectSim') }}
@@ -1604,7 +1604,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>
+1
View File
@@ -5106,6 +5106,7 @@ app.post('/api/:endpoint', (request, response) => {
sim: {
id: 'development-sim',
number: '5551234567',
removable: true,
registered: true,
type: 'registered',
},
+2
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",
}
@@ -27,6 +28,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,
+1
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.",
@@ -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)
+34
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 = {
+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,
@@ -647,24 +779,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
@@ -720,6 +866,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)
sessions[source] = {
@@ -1132,6 +1290,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)
@@ -1139,6 +1298,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,14 +26,14 @@ 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)
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
@@ -62,6 +64,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
@@ -82,7 +169,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 not sim then
@@ -97,30 +189,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
@@ -139,6 +247,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
@@ -146,11 +258,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
@@ -211,6 +319,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
@@ -234,10 +345,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] = {
@@ -257,10 +368,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
@@ -268,6 +384,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
@@ -277,7 +396,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)
@@ -288,7 +407,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
@@ -313,4 +435,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)
+11
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,