ADD - open unique phones by item and hotkey

Register a configurable F1 phone toggle through a server-authoritative open request and retain the selected unique handset by IMEI. Verify usable-item support for every inventory adapter, fail visibly on registration errors, and document the behavior.
This commit is contained in:
Leon.Schmidt
2026-08-17 15:57:56 +02:00
parent 1b453e3456
commit e5a1c2bf40
8 changed files with 186 additions and 34 deletions
+6
View File
@@ -214,6 +214,8 @@ Default entries for unique phones with physical SIM cards:
Do not configure an LB Phone client event or client export. Sky Phone registers the usable items through its server-side inventory adapter.
The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, and `smx`. Resource startup fails visibly if the selected adapter cannot complete that registration.
### QBCore-style item tables
- Set the phone's `unique` value to match `Config.Phone.Unique`.
@@ -235,6 +237,8 @@ Config.Sim.Enabled = true
| `Unique = true` | Every phone item receives its own IMEI. Settings, apps, local data, linked account, and SIM move with the item. The item must not stack. |
| `Unique = false` | Every framework character receives one persistent virtual device. Any configured phone item opens that device. The item may stack. |
With unique phones, using an inventory item selects that exact handset whenever the inventory reports its slot. The F1 hotkey reopens the last selected IMEI; if no handset has been selected yet, the server chooses the first concrete phone slot. The client never supplies a slot or IMEI.
| SIM mode | Behavior |
| --- | --- |
| `Enabled = true` | A registered or anonymous physical SIM item is required for cellular service. |
@@ -363,6 +367,8 @@ The model must also be listed in `Config.Payphones.Props`.
## Commands
`Config.Phone.Keybind` defaults to `F1` and can be rebound in FiveM's key bindings. Set it to `false` to disable the phone hotkey.
| Command | Where | Purpose |
| --- | --- | --- |
| `/phone` | In game | Opens the development phone command when `Config.Phone.DevelopmentCommand` is enabled |
@@ -0,0 +1,62 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const readResourceFile = (path: string) =>
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
const inventoryAdapters = [
['ox', 'source/bridge/server/inventory/ox.lua'],
['qb', 'source/bridge/server/inventory/qb.lua'],
['lj', 'source/bridge/server/inventory/qb.lua'],
['qs', 'source/bridge/server/inventory/qs.lua'],
['codem', 'source/bridge/server/inventory/codem.lua'],
['core', 'source/bridge/server/inventory/core.lua'],
['mf', 'source/bridge/server/inventory/mf.lua'],
['smx', 'source/bridge/server/inventory/smx.lua'],
] as const
describe('phone inventory contracts', () => {
it.each(inventoryAdapters)(
'registers the phone as a usable item through the %s adapter',
(_inventory, path) => {
expect(readResourceFile(path)).toContain(
'function Bridge.Inventory.RegisterUsableItem',
)
},
)
it('fails startup when the selected inventory cannot register the phone item', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
expect(phoneServer).toContain(
'Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone)',
)
expect(phoneServer).toContain('if not usable_registered then')
})
it('opens from a configurable F1 mapping without client-provided device identity', () => {
const config = readResourceFile('config/config.lua')
const phoneClient = readResourceFile('source/client/main.lua')
const phoneServer = readResourceFile('source/server/phone.lua')
expect(config).toContain('Keybind = "F1"')
expect(phoneClient).toContain(
'RegisterKeyMapping("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind)',
)
expect(phoneClient).toContain(
'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
)
expect(phoneServer).toContain(
'Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)',
)
})
it('keeps a server-selected unique handset as the preferred hotkey device', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
expect(phoneServer).toContain('local preferred_device_imeis = {}')
expect(phoneServer).toContain('local preferred_imei = preferred_device_imeis[source]')
expect(phoneServer).toContain('preferred_device_imeis[source] = imei')
})
})
+14 -3
View File
@@ -12,6 +12,16 @@ const phoneServer = readFileSync(
)
describe('server startup contracts', () => {
it('registers the production phone-open callback before the database migration runs', () => {
expect(
phoneServer.indexOf(
'Bridge.Callbacks.Register("sky_phone:device:open-request"',
),
).toBeLessThan(
phoneServer.indexOf('Bridge.Database.AfterMigration("sky_phone"'),
)
})
it('registers the development-open callback before the database migration runs', () => {
expect(manifest.indexOf("'source/server/phone.lua'")).toBeLessThan(
manifest.indexOf("'source/server/db_migrate.lua'"),
@@ -25,11 +35,12 @@ describe('server startup contracts', () => {
)
})
it('queues early development opens until the resource has fully started', () => {
it('queues early opens until the resource has fully started', () => {
expect(phoneServer).toContain(
'AddEventHandler("onServerResourceStart", function(resource_name)',
)
expect(phoneServer).toContain('pending_development_opens[source] = true')
expect(phoneServer).toContain('development_open_handler = open_phone')
expect(phoneServer).toContain('pending_phone_opens[source] = true')
expect(phoneServer).toContain('phone_open_handler = open_phone')
expect(phoneServer).toContain('flush_pending_phone_opens()')
})
})
+1
View File
@@ -27,6 +27,7 @@ Config.Command = "phone"
Config.Phone = {
Item = "phone",
Unique = true, -- true: data follows each phone item; false: one persistent phone per character
Keybind = "F1", -- false disables the configurable phone key mapping
AllowMovement = true, -- true: game input stays active while the mobile phone is open
DevelopmentCommand = true,
DeviceName = "iFruit Phone",
+3
View File
@@ -252,6 +252,9 @@ local german = translate_shared(clone(Locales["en"] or {}))
merge(german, {
CommandDescription = "Öffne dein Handy.",
Controls = {
OpenPhone = "Handy öffnen",
},
TestData = {
CommandDescription = "Testinhalte für alle datenbasierten Handy-Apps erstellen oder aktualisieren.",
Success = "Die Testinhalte sind bereit. Deine Sky-Cloud-Anmeldung lautet {email}. Öffne das Handy erneut, um alle Apps zu aktualisieren.",
+3
View File
@@ -1,5 +1,8 @@
Locales["en"] = {
CommandDescription = "Open your phone.",
Controls = {
OpenPhone = "Open phone",
},
TestData = {
CommandDescription = "Create or refresh test content in every data-driven phone app.",
Success = "Test content is ready. Your Sky Cloud login is {email}. Reopen the phone to refresh every app.",
+18 -1
View File
@@ -435,7 +435,7 @@ end
if Config.Phone.DevelopmentCommand then
RegisterCommand(Config.Command, function()
if is_open then
if is_open or open_requested then
close_phone()
return
end
@@ -443,6 +443,23 @@ if Config.Phone.DevelopmentCommand then
end, false)
end
RegisterCommand("sky_phone_toggle", function()
if is_open or open_requested then
close_phone()
return
end
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
end, false)
if Config.Phone.Keybind then
if type(Config.Phone.Keybind) ~= "string" or Config.Phone.Keybind == "" then
error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
end
RegisterKeyMapping("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind)
end
RegisterNetEvent("sky_phone:testdata:feedback", function(success, detail)
local test_data_locale = locale.TestData
local message = success and test_data_locale.Success or test_data_locale.Failed
+79 -30
View File
@@ -1,20 +1,41 @@
local development_open_handler
local pending_development_opens = {}
local phone_open_handler
local pending_phone_opens = {}
local server_started = false
local function flush_pending_phone_opens()
if not server_started or not phone_open_handler then
return
end
for player_source in pairs(pending_phone_opens) do
pending_phone_opens[player_source] = nil
phone_open_handler(player_source, nil)
end
end
Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)
if not server_started or not phone_open_handler then
pending_phone_opens[source] = true
return { success = true, data = { queued = true } }
end
return { success = phone_open_handler(source, nil) }
end)
Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
if not Config.Phone.DevelopmentCommand then
return { success = false, error = "disabled" }
end
if not server_started or not development_open_handler then
pending_development_opens[source] = true
if not server_started or not phone_open_handler then
pending_phone_opens[source] = true
return { success = true, data = { queued = true } }
end
return { success = development_open_handler(source, nil) }
return { success = phone_open_handler(source, nil) }
end)
AddEventHandler("playerDropped", function()
pending_development_opens[source] = nil
pending_phone_opens[source] = nil
end)
AddEventHandler("onServerResourceStart", function(resource_name)
@@ -23,23 +44,7 @@ AddEventHandler("onServerResourceStart", function(resource_name)
end
server_started = true
if not development_open_handler then
Bridge.Debug("error", "[sky_phone] Server initialization did not register the development phone handler.")
return
end
for player_source in pairs(pending_development_opens) do
pending_development_opens[player_source] = nil
local success, opened = pcall(development_open_handler, player_source, nil)
if not success then
Bridge.Debug(
"error",
"[sky_phone] Queued development phone open failed for source %s: %s",
tostring(player_source),
tostring(opened)
)
end
end
flush_pending_phone_opens()
end)
Bridge.Database.AfterMigration("sky_phone", function()
@@ -50,6 +55,7 @@ SkyPhone = {}
local unique_phones = Config.Phone.Unique ~= false
local sim_cards_enabled = Config.Sim.Enabled ~= false
local sessions = {}
local preferred_device_imeis = {}
local auth_attempts = {}
local operation_attempts = {}
local character_device_cache = {}
@@ -311,6 +317,14 @@ local function resolve_used_slot(source, used_item)
if slot and slot.name == Config.Phone.Item then
return slot
end
Bridge.Debug(
"warn",
"[sky_phone] Usable item callback reported invalid phone slot %s for source %s.",
tostring(slot_id),
tostring(source)
)
return nil, "phone_slot_missing"
end
local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)
@@ -332,17 +346,40 @@ local function resolve_used_slot(source, used_item)
{ always = true }
)
end
if #slots == 1 or (not unique_phones and #slots > 0) then
if not unique_phones and #slots > 0 then
return slots[1]
end
local preferred_imei = preferred_device_imeis[source]
if preferred_imei then
for _, candidate in ipairs(slots) do
if candidate.metadata and candidate.metadata.imei == preferred_imei then
return candidate
end
end
end
table.sort(slots, function(left, right)
local left_slot = tonumber(left.slot)
local right_slot = tonumber(right.slot)
if left_slot and right_slot and left_slot ~= right_slot then
return left_slot < right_slot
end
return tostring(left.slot) < tostring(right.slot)
end)
if slots[1] then
return slots[1]
end
Bridge.Debug(
"warn",
"[sky_phone] Usable item callback did not identify an exact phone slot for source %s (%s candidates).",
"[sky_phone] No phone item slot was available for source %s (%s candidates).",
tostring(source),
tostring(#slots)
)
return nil
return nil, "phone_required"
end
local function ensure_device(source, slot)
@@ -947,15 +984,16 @@ local function open_phone(source, used_item)
tostring(source),
{ always = true }
)
local slot = resolve_used_slot(source, used_item)
local slot, slot_error = resolve_used_slot(source, used_item)
if not slot then
Bridge.Debug(
"debug",
"[sky_phone] Phone open rejected for source %s: no exact inventory slot.",
"[sky_phone] Phone open rejected for source %s: %s.",
tostring(source),
tostring(slot_error),
{ always = true }
)
TriggerClientEvent("sky_phone:device:error", source, "phone_slot_missing")
TriggerClientEvent("sky_phone:device:error", source, slot_error)
return false
end
@@ -995,6 +1033,7 @@ local function open_phone(source, used_item)
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
unlocked = security == nil,
}
preferred_device_imeis[source] = imei
local payload = bootstrap(source, security, true)
Bridge.Debug(
"debug",
@@ -1010,7 +1049,8 @@ local function open_phone(source, used_item)
return true
end
development_open_handler = open_phone
phone_open_handler = open_phone
flush_pending_phone_opens()
function SkyPhone.OpenDeviceForCall(source, imei)
local matches = find_device_slots(source, imei)
@@ -1033,6 +1073,7 @@ function SkyPhone.OpenDeviceForCall(source, imei)
unlocked = security == nil,
}
end
preferred_device_imeis[source] = imei
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
return true
end
@@ -1045,6 +1086,12 @@ Bridge.Debug(
{ always = true }
)
local usable_registered = Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone)
if not usable_registered then
error(("[sky_phone] Inventory '%s' did not register phone item '%s' as usable."):format(
tostring(Bridge.Inventory.GetResourceName()),
tostring(Config.Phone.Item)
))
end
Bridge.Debug(
"debug",
"[sky_phone] Usable item registration returned: %s.",
@@ -1427,6 +1474,7 @@ AddEventHandler("playerDropped", function()
auth_attempts[source] = nil
operation_attempts[source] = nil
character_device_cache[source] = nil
preferred_device_imeis[source] = nil
end)
AddEventHandler("onResourceStop", function(resource_name)
@@ -1435,6 +1483,7 @@ AddEventHandler("onResourceStop", function(resource_name)
auth_attempts = {}
operation_attempts = {}
character_device_cache = {}
preferred_device_imeis = {}
end
end)
end)