mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
ENH - make phone identity and SIM cards configurable
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -335,6 +335,7 @@ async function hydrateDevelopmentPhone(): Promise<void> {
|
||||
sim: {
|
||||
id: 'development-sim',
|
||||
number: '5551234567',
|
||||
removable: true,
|
||||
registered: true,
|
||||
type: 'registered',
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ export type SimType = 'registered' | 'anonymous'
|
||||
export type PhoneSim = {
|
||||
id: string
|
||||
number: string
|
||||
removable: boolean
|
||||
registered: boolean
|
||||
type: SimType
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5106,6 +5106,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
sim: {
|
||||
id: 'development-sim',
|
||||
number: '5551234567',
|
||||
removable: true,
|
||||
registered: true,
|
||||
type: 'registered',
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user