mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-31 08:01:45 +00:00
36b859cabb
* FIX - layer dynamic island above phone camera The phone screen stacking context kept the Dynamic Island below the physical frame and its camera lens. Render the island at device-shell level, position it around the lens, and keep expanded notification spacing in sync. * ADD - preview all dynamic island states Reuse the runtime component for a development-only gallery and size expanded previews to the actual phone display so they cannot be clipped. * FIX - match dynamic island live activity behavior Show each activity only outside its owning app, use reference-sized activity layouts, and remove the separate expand glyph in favor of direct island taps. * FIX - refine dynamic island live controls Keep background activity state separate from foreground visibility so the island remains visible in the compact closed-phone peek. Match the timer, stopwatch, music, collapse, and spacing behavior to the supplied references. * FIX - keep business call availability after closing Stop treating the closed phone UI as the end of business-call readiness. Route background service-line calls only after revalidating the employee, owned device, and registered SIM. * FIX - remove dock apps from home grid The stored Home layout treated dock and grid shortcuts independently, so default dock apps appeared twice. Let the dock own those default placements and migrate saved layouts by removing matching grid shortcuts. * FIX - keep memo island active after closing Memo recording was cancelled as soon as the UI closed, while uploads required an active UI session and reopening a locked phone forced the home route. Keep the recorder bound to the physical device, revalidate device ownership server-side for uploads, preserve the last route behind the lock screen, and render only a compact frame, display, and live Dynamic Island while closed. * ENH - align live activity controls with phone hardware Report compact live-activity state to the client so Space opens Home or the enabled lock screen contextually. Apply the phone hardware volume as a shared output level for app media, games, YouTube playback, radio, and auxiliary sounds, and render the volume speaker icon in grey. * FIX - hide paused music live activity The music island previously depended only on the selected track, which remains set after playback pauses. Require active playback as well so the island closes when music stops while preserving the track for later resume. --------- Co-authored-by: DerEchteAlec <bycraky@gmail.com>
1005 lines
32 KiB
Lua
1005 lines
32 KiB
Lua
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 phone_open_handler then
|
|
pending_phone_opens[source] = true
|
|
return { success = true, data = { queued = true } }
|
|
end
|
|
|
|
return { success = phone_open_handler(source, nil) }
|
|
end)
|
|
|
|
AddEventHandler("playerDropped", function()
|
|
pending_phone_opens[source] = nil
|
|
end)
|
|
|
|
AddEventHandler("onServerResourceStart", function(resource_name)
|
|
if resource_name ~= GetCurrentResourceName() then
|
|
return
|
|
end
|
|
|
|
server_started = true
|
|
flush_pending_phone_opens()
|
|
end)
|
|
|
|
Bridge.Database.AfterMigration("sky_phone", function()
|
|
Bridge.Debug("debug", "[sky_phone] Server initialization started after database migration.", { always = true })
|
|
|
|
SkyPhone = {}
|
|
|
|
local unique_phones = Config.Phone.Unique ~= false
|
|
local sim_cards_enabled = Config.Sim.Enabled ~= false
|
|
local sessions = {}
|
|
local preferred_device_imeis = {}
|
|
local equipped_phone_numbers = {}
|
|
local equipped_phone_identifiers = {}
|
|
local equipped_phone_sources = {}
|
|
local operation_attempts = {}
|
|
local character_device_cache = {}
|
|
|
|
local function trim(value)
|
|
if type(value) ~= "string" then
|
|
return nil
|
|
end
|
|
|
|
return value:match("^%s*(.-)%s*$")
|
|
end
|
|
|
|
local function affected_rows(result)
|
|
if type(result) == "number" then
|
|
return result
|
|
end
|
|
|
|
return type(result) == "table" and tonumber(result.affectedRows) or 0
|
|
end
|
|
|
|
local function reserve_imei()
|
|
local imei = SkyPhoneImei.Reserve(function()
|
|
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
|
local uuid = rows[1] and rows[1].id
|
|
if type(uuid) ~= "string" then
|
|
error("[sky_phone] Database did not generate entropy for an IMEI.")
|
|
end
|
|
return uuid
|
|
end, function(candidate)
|
|
local result = Bridge.Database.Query([[
|
|
INSERT IGNORE INTO `sky_phone_devices` (`imei`, `device_name`)
|
|
VALUES (?, ?)
|
|
]], { candidate, Config.Phone.DeviceName })
|
|
return affected_rows(result) > 0
|
|
end)
|
|
|
|
if not imei then
|
|
error("[sky_phone] Could not generate a unique IMEI after 20 attempts.")
|
|
end
|
|
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 discard_equipped_phone_number(source)
|
|
local phone_number = equipped_phone_numbers[source]
|
|
local identifier = equipped_phone_identifiers[source]
|
|
equipped_phone_numbers[source] = nil
|
|
equipped_phone_identifiers[source] = nil
|
|
if identifier then
|
|
equipped_phone_numbers[identifier] = nil
|
|
end
|
|
if phone_number and equipped_phone_sources[phone_number] == source then
|
|
equipped_phone_sources[phone_number] = nil
|
|
end
|
|
return phone_number
|
|
end
|
|
|
|
local function cache_equipped_phone_number(source, identifier, phone_number)
|
|
local previous_number = discard_equipped_phone_number(source)
|
|
equipped_phone_identifiers[source] = identifier
|
|
equipped_phone_numbers[source] = phone_number
|
|
if identifier then
|
|
equipped_phone_numbers[identifier] = phone_number
|
|
end
|
|
if phone_number then
|
|
equipped_phone_sources[phone_number] = source
|
|
end
|
|
if previous_number ~= phone_number then
|
|
TriggerEvent("sky_phone:server:phoneNumberChanged", source, phone_number)
|
|
end
|
|
end
|
|
|
|
local function remove_equipped_phone_number(source)
|
|
local previous_number = discard_equipped_phone_number(source)
|
|
if previous_number then
|
|
TriggerEvent("sky_phone:server:phoneNumberChanged", source, nil)
|
|
end
|
|
end
|
|
|
|
local function migrated_device_for_character(source)
|
|
local identifier = character_identifier(source)
|
|
if not identifier then
|
|
return nil
|
|
end
|
|
|
|
local rows = Bridge.Database.Query([[
|
|
SELECT mapping.`device_imei`
|
|
FROM `sky_phone_migration_owners` mapping
|
|
JOIN `sky_phone_character_devices` character_device
|
|
ON character_device.`owner_identifier` = mapping.`owner_identifier`
|
|
AND character_device.`device_imei` = mapping.`device_imei`
|
|
JOIN `sky_phone_devices` device ON device.`imei` = mapping.`device_imei`
|
|
WHERE mapping.`owner_identifier` = ?
|
|
ORDER BY mapping.`created_at` DESC
|
|
LIMIT 1
|
|
]], { identifier })
|
|
local imei = rows[1] and rows[1].device_imei or nil
|
|
if imei and not SkyPhoneImei.IsValid(imei) then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Phone migration mapping contains invalid IMEI '%s' for '%s'.",
|
|
tostring(imei),
|
|
identifier
|
|
)
|
|
return nil
|
|
end
|
|
return imei
|
|
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 = {}
|
|
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
|
|
end
|
|
|
|
if #matches > 1 then
|
|
Bridge.Debug(
|
|
"warn",
|
|
"[sky_phone] Source %s has %s items with duplicated IMEI %s.",
|
|
tostring(source),
|
|
tostring(#matches),
|
|
imei
|
|
)
|
|
end
|
|
|
|
return matches
|
|
end
|
|
|
|
local function resolve_used_slot(source, used_item)
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Resolving used item for source %s: type=%s slot=%s id=%s name=%s.",
|
|
tostring(source),
|
|
type(used_item),
|
|
tostring(used_item and used_item.slot),
|
|
tostring(used_item and used_item.id),
|
|
tostring(used_item and used_item.name),
|
|
{ always = true }
|
|
)
|
|
|
|
local slot_id = used_item and (used_item.slot or used_item.id)
|
|
if slot_id then
|
|
local slot = Bridge.Inventory.GetSlot(source, slot_id)
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Inventory slot lookup for source %s slot %s returned name=%s amount=%s metadata_imei=%s.",
|
|
tostring(source),
|
|
tostring(slot_id),
|
|
tostring(slot and slot.name),
|
|
tostring(slot and (slot.amount or slot.count)),
|
|
tostring(slot and slot.metadata and slot.metadata.imei),
|
|
{ always = true }
|
|
)
|
|
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)
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Inventory fallback found %s phone slot candidates for source %s.",
|
|
tostring(#slots),
|
|
tostring(source),
|
|
{ always = true }
|
|
)
|
|
for _, candidate in ipairs(slots) do
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Candidate slot=%s name=%s amount=%s metadata_imei=%s.",
|
|
tostring(candidate.slot),
|
|
tostring(candidate.name),
|
|
tostring(candidate.amount or candidate.count),
|
|
tostring(candidate.metadata and candidate.metadata.imei),
|
|
{ always = true }
|
|
)
|
|
end
|
|
|
|
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] No phone item slot was available for source %s (%s candidates).",
|
|
tostring(source),
|
|
tostring(#slots)
|
|
)
|
|
return nil, "phone_required"
|
|
end
|
|
|
|
local function ensure_device(source, slot)
|
|
local amount = tonumber(slot.amount or slot.count) or 0
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Ensuring device for source %s slot=%s amount=%s existing_imei=%s inventory=%s.",
|
|
tostring(source),
|
|
tostring(slot.slot),
|
|
tostring(amount),
|
|
tostring(slot.metadata and slot.metadata.imei),
|
|
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"
|
|
end
|
|
|
|
local metadata = slot.metadata or {}
|
|
local imei = metadata.imei
|
|
if imei and not SkyPhoneImei.IsValid(imei) then
|
|
Bridge.Debug("warn", "[sky_phone] Phone item in slot %s has invalid IMEI metadata.", tostring(slot.slot))
|
|
return nil, "invalid_imei"
|
|
end
|
|
|
|
if not imei then
|
|
imei = migrated_device_for_character(source)
|
|
if imei then
|
|
metadata.imei = imei
|
|
local metadata_written = Bridge.Inventory.SetSlotMetadata(source, slot.slot, metadata)
|
|
if not metadata_written then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Inventory '%s' could not adopt migrated phone metadata for source %s slot %s.",
|
|
Bridge.Inventory.GetResourceName(),
|
|
tostring(source),
|
|
tostring(slot.slot)
|
|
)
|
|
return nil, "metadata_unsupported"
|
|
end
|
|
Bridge.Debug(
|
|
"info",
|
|
"[sky_phone] Adopted LB Phone migrated device %s for source %s.",
|
|
imei,
|
|
tostring(source),
|
|
{ always = true }
|
|
)
|
|
return imei
|
|
end
|
|
|
|
imei = reserve_imei()
|
|
metadata.imei = imei
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Reserved IMEI %s; writing metadata to source %s slot %s.",
|
|
imei,
|
|
tostring(source),
|
|
tostring(slot.slot),
|
|
{ always = true }
|
|
)
|
|
local metadata_written = Bridge.Inventory.SetSlotMetadata(source, slot.slot, metadata)
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Metadata write result for source %s slot %s IMEI %s: %s.",
|
|
tostring(source),
|
|
tostring(slot.slot),
|
|
imei,
|
|
tostring(metadata_written),
|
|
{ always = true }
|
|
)
|
|
if not metadata_written then
|
|
Bridge.Database.Query("DELETE FROM `sky_phone_devices` WHERE `imei` = ?", { imei })
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Inventory '%s' could not write phone metadata for source %s slot %s.",
|
|
Bridge.Inventory.GetResourceName(),
|
|
tostring(source),
|
|
tostring(slot.slot)
|
|
)
|
|
return nil, "metadata_unsupported"
|
|
end
|
|
return imei
|
|
end
|
|
|
|
Bridge.Database.Query([[
|
|
INSERT IGNORE INTO `sky_phone_devices` (`imei`, `device_name`)
|
|
VALUES (?, ?)
|
|
]], { imei, Config.Phone.DeviceName })
|
|
|
|
return imei
|
|
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`, 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`
|
|
WHERE d.`imei` = ?
|
|
LIMIT 1
|
|
]], { imei })
|
|
return rows[1]
|
|
end
|
|
|
|
local function load_device_data(imei)
|
|
local rows = Bridge.Database.Query([[
|
|
SELECT `namespace`, `payload`, `revision`
|
|
FROM `sky_phone_device_data`
|
|
WHERE `device_imei` = ?
|
|
]], { imei })
|
|
local data = {}
|
|
for _, row in ipairs(rows) do
|
|
data[row.namespace] = {
|
|
payload = json.decode(row.payload),
|
|
revision = tonumber(row.revision) or 0,
|
|
}
|
|
end
|
|
return data
|
|
end
|
|
|
|
local function bootstrap(source, security, security_loaded)
|
|
local session, error_response = SkyPhone.RequireDeviceSession(source)
|
|
if not session then
|
|
return nil, error_response
|
|
end
|
|
|
|
local device = load_device(session.imei)
|
|
if not device then
|
|
error(("[sky_phone] Active IMEI %s has no device row."):format(session.imei))
|
|
end
|
|
|
|
session.account_id = device.account_id and tonumber(device.account_id) or nil
|
|
session.account_email = session.account_id and device.email or nil
|
|
|
|
local identifier = character_identifier(source)
|
|
cache_equipped_phone_number(source, identifier, device.phone_number)
|
|
|
|
return {
|
|
token = session.token,
|
|
security = SkyPhoneSecurity.Status(device.imei, security, security_loaded),
|
|
device = {
|
|
imei = device.imei,
|
|
name = device.device_name,
|
|
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,
|
|
data = load_device_data(device.imei),
|
|
},
|
|
account = device.account_id and {
|
|
id = tonumber(device.account_id),
|
|
email = device.email,
|
|
devices = SkyPhoneAccounts.List(device.account_id, device.imei),
|
|
} or nil,
|
|
notes = SkyPhoneNotes.List(device.account_id, device.imei),
|
|
memos = SkyPhoneMemos.List(device.account_id, device.imei),
|
|
player = {
|
|
firstName = trim(Bridge.Framework.GetFirstname(source)) or "",
|
|
lastName = trim(Bridge.Framework.GetLastname(source)) or "",
|
|
},
|
|
}
|
|
end
|
|
|
|
local function refresh_source(source)
|
|
local payload = bootstrap(source)
|
|
if payload then
|
|
TriggerClientEvent("sky_phone:device:updated", source, payload)
|
|
end
|
|
end
|
|
|
|
SkyPhone.EnsureDevice = ensure_device
|
|
SkyPhone.FindDeviceSlots = find_device_slots
|
|
SkyPhone.LoadDevice = load_device
|
|
SkyPhone.RefreshSource = refresh_source
|
|
|
|
local function online_source_for_identifier(identifier)
|
|
for _, player in ipairs(GetPlayers()) do
|
|
local player_source = tonumber(player)
|
|
if player_source and character_identifier(player_source) == identifier then
|
|
return player_source
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function resolve_equipped_phone_number(source)
|
|
local identifier = character_identifier(source)
|
|
if not identifier then
|
|
return nil
|
|
end
|
|
|
|
local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)
|
|
if not slots[1] then
|
|
remove_equipped_phone_number(source)
|
|
return nil
|
|
end
|
|
|
|
local imei
|
|
if unique_phones then
|
|
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)
|
|
|
|
local preferred_imei = preferred_device_imeis[source]
|
|
for _, slot in ipairs(slots) do
|
|
local amount = tonumber(slot.amount or slot.count) or 0
|
|
local slot_imei = slot.metadata and slot.metadata.imei or nil
|
|
if amount == 1 and SkyPhoneImei.IsValid(slot_imei) then
|
|
if slot_imei == preferred_imei then
|
|
imei = slot_imei
|
|
break
|
|
end
|
|
imei = imei or slot_imei
|
|
end
|
|
end
|
|
else
|
|
imei = load_character_device(source, identifier)
|
|
end
|
|
|
|
if not imei then
|
|
remove_equipped_phone_number(source)
|
|
return nil
|
|
end
|
|
|
|
local device = load_device(imei)
|
|
local phone_number = device and device.phone_number or nil
|
|
cache_equipped_phone_number(source, identifier, phone_number)
|
|
return phone_number
|
|
end
|
|
|
|
function SkyPhone.GetEquippedPhoneNumber(player)
|
|
if type(player) == "number" then
|
|
if player <= 0 or player ~= math.floor(player) then
|
|
return nil
|
|
end
|
|
return resolve_equipped_phone_number(player)
|
|
end
|
|
if type(player) ~= "string" or player == "" then
|
|
return nil
|
|
end
|
|
|
|
local player_source = online_source_for_identifier(player)
|
|
return player_source and resolve_equipped_phone_number(player_source) or nil
|
|
end
|
|
|
|
function SkyPhone.GetSourceFromNumber(phone_number)
|
|
local normalized = SkyPhoneSimNumber.Normalize(
|
|
phone_number,
|
|
Config.Sim.NumberLength,
|
|
Config.Sim.NumberPrefix
|
|
)
|
|
if not normalized then
|
|
return nil
|
|
end
|
|
|
|
local player_source = equipped_phone_sources[normalized]
|
|
if player_source and resolve_equipped_phone_number(player_source) == normalized then
|
|
return player_source
|
|
end
|
|
for _, player in ipairs(GetPlayers()) do
|
|
player_source = tonumber(player)
|
|
if player_source and resolve_equipped_phone_number(player_source) == normalized then
|
|
return player_source
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function SkyPhone.FormatNumber(phone_number)
|
|
return SkyPhoneSimNumber.Format(
|
|
phone_number,
|
|
Config.Sim.NumberGroups,
|
|
Config.Sim.NumberLength,
|
|
Config.Sim.NumberPrefix
|
|
)
|
|
end
|
|
|
|
function SkyPhone.RequireDeviceSession(source)
|
|
local session = sessions[source]
|
|
if not session then
|
|
return nil, { success = false, error = "device_not_open" }
|
|
end
|
|
|
|
local matches = find_device_slots(source, session.imei)
|
|
if not matches[1] then
|
|
SkyPhoneCompanies.ClearCallAvailability(source)
|
|
sessions[source] = nil
|
|
remove_equipped_phone_number(source)
|
|
TriggerClientEvent("sky_phone:device:invalidated", source)
|
|
return nil, { success = false, error = "device_not_owned" }
|
|
end
|
|
session.slot = matches[1].slot
|
|
return session
|
|
end
|
|
|
|
function SkyPhone.RequireSession(source)
|
|
local session, error_response = SkyPhone.RequireDeviceSession(source)
|
|
if not session then
|
|
return nil, error_response
|
|
end
|
|
if not session.unlocked then
|
|
return nil, { success = false, error = "device_locked" }
|
|
end
|
|
return session
|
|
end
|
|
|
|
function SkyPhone.AllowOperation(source, operation, maximum, window_seconds)
|
|
local now = os.time()
|
|
operation_attempts[source] = operation_attempts[source] or {}
|
|
local attempts = operation_attempts[source][operation]
|
|
if not attempts or now - attempts.started_at >= window_seconds then
|
|
operation_attempts[source][operation] = { count = 1, started_at = now }
|
|
return true
|
|
end
|
|
if attempts.count >= maximum then
|
|
Bridge.Debug(
|
|
"warn",
|
|
"[sky_phone] Rate limit '%s' exceeded by source %s.",
|
|
operation,
|
|
tostring(source)
|
|
)
|
|
return false
|
|
end
|
|
attempts.count = attempts.count + 1
|
|
return true
|
|
end
|
|
|
|
function SkyPhone.RequireAccount(source)
|
|
local session, error_response = SkyPhone.RequireSession(source)
|
|
if not session then
|
|
return nil, error_response
|
|
end
|
|
if not session.account_id then
|
|
return nil, { success = false, error = "not_authenticated" }
|
|
end
|
|
return {
|
|
id = session.account_id,
|
|
email = session.account_email,
|
|
imei = session.imei,
|
|
}
|
|
end
|
|
|
|
function SkyPhone.NotifyAccount(account_id, event_name, data)
|
|
for source in pairs(sessions) do
|
|
local account = SkyPhone.RequireAccount(source)
|
|
if account and account.id == tonumber(account_id) then
|
|
TriggerClientEvent(event_name, source, data)
|
|
end
|
|
end
|
|
end
|
|
|
|
function SkyPhone.NotifyAccountDevices(account_id, event_name, data)
|
|
local rows = Bridge.Database.Query([[
|
|
SELECT d.`imei`, d.`device_name`, settings.`payload` AS `settings`
|
|
FROM `sky_phone_devices` d
|
|
LEFT JOIN `sky_phone_device_data` settings
|
|
ON settings.`device_imei` = d.`imei` AND settings.`namespace` = 'settings'
|
|
WHERE d.`account_id` = ?
|
|
]], { account_id })
|
|
local devices = {}
|
|
for _, row in ipairs(rows) do
|
|
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
|
|
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
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function SkyPhone.RefreshAccount(account_id)
|
|
for source in pairs(sessions) do
|
|
local account = SkyPhone.RequireAccount(source)
|
|
if account and account.id == tonumber(account_id) then
|
|
refresh_source(source)
|
|
end
|
|
end
|
|
end
|
|
|
|
function SkyPhone.RefreshDevice(imei)
|
|
for source, session in pairs(sessions) do
|
|
if session.imei == imei then
|
|
refresh_source(source)
|
|
end
|
|
end
|
|
end
|
|
|
|
local function open_phone(source, used_item)
|
|
local opened_at = GetGameTimer()
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Usable phone callback invoked for source %s.",
|
|
tostring(source),
|
|
{ always = true }
|
|
)
|
|
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: %s.",
|
|
tostring(source),
|
|
tostring(slot_error),
|
|
{ always = true }
|
|
)
|
|
TriggerClientEvent("sky_phone:device:error", source, slot_error)
|
|
return false
|
|
end
|
|
|
|
local imei, error_code = ensure_device(source, slot)
|
|
if not imei then
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Phone open rejected for source %s slot %s: %s.",
|
|
tostring(source),
|
|
tostring(slot.slot),
|
|
tostring(error_code),
|
|
{ always = true }
|
|
)
|
|
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 = SkyPhoneSecurity.Load(imei)
|
|
if sessions[source] and sessions[source].imei ~= imei then
|
|
SkyPhoneCompanies.ClearCallAvailability(source)
|
|
end
|
|
sessions[source] = {
|
|
imei = imei,
|
|
slot = slot.slot,
|
|
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",
|
|
"[sky_phone] Triggering client open for source %s slot %s IMEI %s account_linked=%s after %sms.",
|
|
tostring(source),
|
|
tostring(slot.slot),
|
|
imei,
|
|
tostring(payload.account ~= nil),
|
|
tostring(GetGameTimer() - opened_at),
|
|
{ always = true }
|
|
)
|
|
TriggerClientEvent("sky_phone:device:open", source, payload)
|
|
return true
|
|
end
|
|
|
|
phone_open_handler = open_phone
|
|
flush_pending_phone_opens()
|
|
|
|
function SkyPhone.OpenDeviceForCall(source, imei)
|
|
local matches = find_device_slots(source, imei)
|
|
if not matches[1] then
|
|
Bridge.Debug("warn", "[sky_phone] Could not open ringing device %s for source %s.", tostring(imei), tostring(source))
|
|
return false
|
|
end
|
|
local security = SkyPhoneSecurity.Load(imei)
|
|
local existing_session = sessions[source]
|
|
if existing_session and existing_session.imei ~= imei then
|
|
SkyPhoneCompanies.ClearCallAvailability(source)
|
|
end
|
|
if existing_session and existing_session.imei == imei then
|
|
existing_session.slot = matches[1].slot
|
|
else
|
|
sessions[source] = {
|
|
imei = imei,
|
|
slot = matches[1].slot,
|
|
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
|
|
unlocked = security == nil,
|
|
}
|
|
end
|
|
preferred_device_imeis[source] = imei
|
|
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
|
|
return true
|
|
end
|
|
|
|
Bridge.Debug(
|
|
"debug",
|
|
"[sky_phone] Registering usable item '%s' through inventory '%s'.",
|
|
Config.Phone.Item,
|
|
tostring(Bridge.Inventory.GetResourceName()),
|
|
{ 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.",
|
|
tostring(usable_registered),
|
|
{ always = true }
|
|
)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:device:close", function(source)
|
|
sessions[source] = nil
|
|
return { success = true }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:device:equipped-number", function(source)
|
|
return {
|
|
success = true,
|
|
data = { phoneNumber = SkyPhone.GetEquippedPhoneNumber(source) },
|
|
}
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:device:notification-open", function(source, data)
|
|
if not SkyPhone.AllowOperation(source, "notification_open", 10, 60)
|
|
or type(data) ~= "table" or type(data.imei) ~= "string"
|
|
then
|
|
return { success = false, error = "invalid_request" }
|
|
end
|
|
if not SkyPhone.OpenDeviceForCall(source, data.imei) then
|
|
return { success = false, error = "device_not_owned" }
|
|
end
|
|
return { success = true }
|
|
end)
|
|
|
|
AddEventHandler("playerDropped", function()
|
|
SkyPhoneCompanies.ClearCallAvailability(source)
|
|
sessions[source] = nil
|
|
discard_equipped_phone_number(source)
|
|
operation_attempts[source] = nil
|
|
character_device_cache[source] = nil
|
|
preferred_device_imeis[source] = nil
|
|
end)
|
|
|
|
AddEventHandler("onResourceStop", function(resource_name)
|
|
if resource_name == GetCurrentResourceName() then
|
|
sessions = {}
|
|
operation_attempts = {}
|
|
character_device_cache = {}
|
|
preferred_device_imeis = {}
|
|
equipped_phone_numbers = {}
|
|
equipped_phone_identifiers = {}
|
|
equipped_phone_sources = {}
|
|
end
|
|
end)
|
|
end)
|