FIX - complete LB custom app compatibility (#11)

* FIX - preserve LB custom app lifecycle

* FIX - reset LB export aliases before startup

* FIX - report competing custom app providers

* FIX - identify LB app frames as live NUI

* FIX - complete LB custom app compatibility

* FIX - provide LB PicChat runtime exports

* FIX - route LB PicChat through Sky Phone

* FIX - return cached LB equipped phone number

* FIX - harden PicChat compatibility migration

* ENH - complete modular phone provider and Creator APIs

* DOC - document the Sky Phone Creator API

---------

Co-authored-by: DerEchteAlec <bycraky@gmail.com>
Co-authored-by: DerEchteAlec <alec.schitzkat@luwan.io>
This commit is contained in:
Leon.Schmidt
2026-08-20 18:19:16 +02:00
committed by GitHub
parent 9aec3eefef
commit 30559048fd
91 changed files with 11860 additions and 2943 deletions
+100
View File
@@ -0,0 +1,100 @@
local net_events = {}
local nui_messages = {}
local focus_claim = false
local joined_channel = nil
local leave_count = 0
local callback_result = { success = true }
local callback_requests = {}
Bridge = {
Calls = {
Join = function(channel)
joined_channel = channel
return channel > 0
end,
Leave = function()
leave_count = leave_count + 1
end,
},
Callbacks = {
Trigger = function(name, payload)
callback_requests[#callback_requests + 1] = { name = name, payload = payload }
if name == "sky_phone:calls:dial" then
assert(payload.phoneNumber == "5550101" and payload.company == nil, "dial payload changed")
else
assert(payload.id == "call-1", "call actions must use the active authoritative call ID")
end
return callback_result
end,
},
Debug = function() end,
}
SkyPhoneFocus = {
SetCall = function(active)
focus_claim = active
end,
}
function RegisterNetEvent(name, callback)
net_events[name] = callback
end
function SendNUIMessage(message)
nui_messages[#nui_messages + 1] = message
end
function TriggerEvent() end
dofile("sky_phone/source/client/calls.lua")
local success, error_code = SkyPhoneCalls.Dial(nil, nil)
assert(not success and error_code == "invalid_request", "empty dial target must be rejected")
assert(SkyPhoneCalls.Dial("5550101"), "valid dial target must reach the server")
assert(SkyPhoneCalls.GetActive() == nil, "inactive calls must not expose a snapshot")
success, error_code = SkyPhoneCalls.Hangup()
assert(not success and error_code == "call_not_found", "hangup must reject missing calls locally")
net_events["sky_phone:call:incoming"]({
id = "call-1",
state = "ringing",
direction = "incoming",
otherNumber = "5550102",
device = { imei = "imei-1", name = "Test Phone" },
})
assert(SkyPhoneCalls.IsActive() and focus_claim, "incoming call must become active and claim focus")
assert(nui_messages[#nui_messages].type == "call:incoming", "incoming call must reach NUI")
local snapshot = assert(SkyPhoneCalls.GetActive())
snapshot.state = "ended"
snapshot.device.name = "Mutated"
local fresh_snapshot = assert(SkyPhoneCalls.GetActive())
assert(fresh_snapshot.state == "ringing", "call snapshots must not mutate authoritative client state")
assert(fresh_snapshot.device.name == "Test Phone", "nested call snapshot state must also be isolated")
assert(SkyPhoneCalls.Answer(), "incoming ringing calls must be answerable through the server callback")
assert(callback_requests[#callback_requests].name == "sky_phone:calls:answer", "answer callback changed")
assert(SkyPhoneCalls.Decline(), "incoming ringing calls must be declineable through the server callback")
assert(callback_requests[#callback_requests].name == "sky_phone:calls:decline", "decline callback changed")
net_events["sky_phone:call:state"]({
id = "call-1",
state = "connected",
direction = "incoming",
channel = 42,
})
assert(joined_channel == 42 and not focus_claim, "connected call must join voice and release attention focus")
success, error_code = SkyPhoneCalls.Answer()
assert(not success and error_code == "call_not_found", "connected calls must not be answered twice")
callback_result = { success = false, error = "request_failed" }
success, error_code = SkyPhoneCalls.Hangup()
assert(not success and error_code == "request_failed", "call action failures must remain visible to adapters")
callback_result = { success = true }
assert(SkyPhoneCalls.Hangup(), "connected calls must use the authoritative hangup callback")
assert(callback_requests[#callback_requests].name == "sky_phone:calls:hangup", "hangup callback changed")
net_events["sky_phone:call:state"]({ id = "call-1", state = "ended" })
assert(not SkyPhoneCalls.IsActive() and leave_count == 1, "ended call must clear state and leave voice")
print("Client call runtime tests passed")
+26
View File
@@ -67,6 +67,7 @@ end
function CreateCam(name, active)
assert(name == "DEFAULT_SCRIPTED_CAMERA" and active, "selfie camera must be created active")
camera_created = true
camera_destroyed = false
return 73
end
@@ -122,4 +123,29 @@ assert(close_enough(camera_target.z, 2.73))
assert(response_from("camera:setFacing", { front = false }).success)
assert(camera_destroyed and not scripted_camera_rendering, "rear mode must release the selfie camera")
assert(type(SkyPhoneCamera.EnableWalkable) == "function", "walkable camera enable seam must exist")
assert(type(SkyPhoneCamera.DisableWalkable) == "function", "walkable camera disable seam must exist")
assert(type(SkyPhoneCamera.SetSelfie) == "function", "selfie camera seam must exist")
assert(type(SkyPhoneCamera.ToggleFrozen) == "function", "frozen camera seam must exist")
assert(type(SkyPhoneCamera.SetFlashlight) == "function", "flashlight seam must exist")
assert(type(SkyPhoneCamera.GetState) == "function", "camera state seam must exist")
SkyPhoneCamera.SetFlashlight(true)
assert(SkyPhoneCamera.GetState().flashEnabled, "flashlight state must report enabled")
SkyPhoneCamera.SetFlashlight(false)
assert(not SkyPhoneCamera.GetState().flashEnabled, "flashlight state must report disabled")
SkyPhoneCamera.EnableWalkable(true)
local walkable_state = SkyPhoneCamera.GetState()
assert(walkable_state.walkable, "walkable camera must report enabled")
assert(walkable_state.selfie, "walkable camera must preserve selfie mode")
assert(walkable_state.active, "walkable camera must open the camera")
SkyPhoneCamera.SetSelfie(false)
assert(not SkyPhoneCamera.GetState().selfie, "selfie camera must switch back to rear mode")
SkyPhoneCamera.ToggleFrozen()
SkyPhoneCamera.DisableWalkable()
local closed_state = SkyPhoneCamera.GetState()
assert(not closed_state.walkable, "walkable camera must report disabled")
assert(not closed_state.active, "walkable camera disable must close the camera")
print("Client camera tests passed")
+133
View File
@@ -1,6 +1,35 @@
local disabled_controls = {}
local all_controls_disabled = {}
local firing_disabled = false
local event_handlers = {}
local nui_callbacks = {}
local nui_focus = nil
local nui_keep_input = nil
Config = { Phone = { AllowMovement = true } }
Bridge = { Debug = function() end }
function CreateThread(callback)
assert(type(callback) == "function", "focus runtime must register its control thread")
end
function AddEventHandler(event_name, callback)
event_handlers[event_name] = callback
end
function RegisterNUICallback(callback_name, callback)
nui_callbacks[callback_name] = callback
end
function SetNuiFocus(focused, cursor)
nui_focus = { focused = focused, cursor = cursor }
end
function SetNuiFocusKeepInput(keep_input)
nui_keep_input = keep_input
end
function TriggerEvent() end
function DisableControlAction(group, control, disabled)
assert(group == 0 and disabled, "phone controls must be disabled in the primary input group")
@@ -29,6 +58,8 @@ local function resolve(overrides)
call_focus = false,
camera_active = false,
camera_nui_focused = true,
cursor_disabled = false,
external_game_input = nil,
is_open = false,
notification_focus = false,
payphone_focus = false,
@@ -90,6 +121,73 @@ assert(
"a focused phone text input must block GTA controls without hiding the NUI cursor"
)
local external_movement_phone = resolve({
external_game_input = true,
is_open = true,
})
assert(
external_movement_phone.cursor
and external_movement_phone.focused
and external_movement_phone.keep_input
and external_movement_phone.game_input
and not external_movement_phone.block_game,
"an external input claim must preserve movement while the app cursor is active"
)
local external_movement_typing_phone = resolve({
external_game_input = true,
is_open = true,
text_input_focused = true,
})
assert(
external_movement_typing_phone.cursor
and external_movement_typing_phone.focused
and external_movement_typing_phone.keep_input
and external_movement_typing_phone.game_input
and external_movement_typing_phone.block_game,
"a focused text input must override an external movement claim"
)
local external_typing_phone = resolve({
allow_movement = true,
external_game_input = false,
is_open = true,
})
assert(
external_typing_phone.cursor
and external_typing_phone.focused
and not external_typing_phone.keep_input
and not external_typing_phone.game_input
and external_typing_phone.block_game,
"an external typing claim must block GTA input"
)
local movable_cursor_disabled_phone = resolve({
allow_movement = true,
cursor_disabled = true,
is_open = true,
})
assert(
not movable_cursor_disabled_phone.cursor
and movable_cursor_disabled_phone.focused
and movable_cursor_disabled_phone.keep_input
and movable_cursor_disabled_phone.game_input
and not movable_cursor_disabled_phone.block_game
and not movable_cursor_disabled_phone.block_look,
"LB noFocus must preserve movement and camera look without retaining the NUI cursor"
)
local stationary_cursor_disabled_phone = resolve({
cursor_disabled = true,
is_open = true,
})
assert(
not stationary_cursor_disabled_phone.cursor
and stationary_cursor_disabled_phone.focused
and not stationary_cursor_disabled_phone.keep_input,
"LB noFocus must hide the cursor even when phone movement is disabled"
)
SkyPhoneFocus.ApplyFocusedControls()
for _, group in ipairs({ 0, 1, 2 }) do
assert(all_controls_disabled[group], ("focused phone cursor must block input group %d while typing"):format(group))
@@ -182,4 +280,39 @@ assert(payphone_closed_behind_phone.focused, "releasing payphone focus must not
local suspended = resolve({ activity_suspended = true, call_focus = true, is_open = true })
assert(not suspended.focused and not suspended.keep_input, "suspended activities must mask every focus claim")
SkyPhoneFocus.SetPhone(true, true)
assert(
nui_focus.focused and not nui_focus.cursor and nui_keep_input,
"runtime phone focus must preserve the no-focus opening contract"
)
SkyPhoneFocus.SetPhone(false)
SkyPhoneFocus.SetPhone(true)
assert(nui_focus.cursor, "closing the phone must clear the previous no-focus claim")
local external_success, external_error = SkyPhoneFocus.SetExternalGameInput("custom_app", true)
assert(external_success and external_error == nil and nui_keep_input, "external movement claim must apply")
external_success, external_error = SkyPhoneFocus.SetExternalGameInput("custom_app", false)
assert(external_success and external_error == nil and not nui_keep_input, "external typing claim must apply")
event_handlers["onClientResourceStop"]("custom_app")
assert(nui_keep_input, "resource stop must restore configured phone movement")
SkyPhoneFocus.SetPhone(false)
external_success, external_error = SkyPhoneFocus.SetExternalGameInput("custom_app", true)
assert(not external_success and external_error == "phone_closed", "closed phones must reject external focus claims")
local notification_result
SkyPhoneFocus.SetPhone(false)
nui_callbacks["notification:focus"]({ active = true }, function(result)
notification_result = result
end)
assert(notification_result.success and nui_focus.focused, "notification focus must be applied through the focus owner")
SkyPhoneFocus.BeginNuiHydration()
SkyPhoneFocus.Reapply()
assert(not nui_focus.focused, "CEF hydration must discard browser-owned notification focus")
SkyPhoneFocus.Reset()
assert(not nui_focus.focused and not nui_focus.cursor and not nui_keep_input, "focus reset must release NUI input")
print("Client focus tests passed")
+46
View File
@@ -0,0 +1,46 @@
local nui_callbacks = {}
local waypoint = nil
function RegisterNUICallback(name, callback)
nui_callbacks[name] = callback
end
function PlayerPedId()
return 7
end
function GetEntityCoords(ped)
assert(ped == 7, "location must use the local player ped")
return { x = 12.5, y = -4.0, z = 80.25 }
end
function SetNewWaypoint(x, y)
waypoint = { x = x, y = y }
end
dofile("sky_phone/source/client/location.lua")
local coords_result
nui_callbacks["map:getPlayerCoords"]({}, function(result)
coords_result = result
end)
assert(coords_result.success and coords_result.data.coords.z == 80.25, "map coordinates changed")
local marker_result
nui_callbacks["map:setWaypoint"]({ coords = { x = 100, y = 200 } }, function(result)
marker_result = result
end)
assert(marker_result.success and waypoint.x == 100 and waypoint.y == 200, "valid waypoint must reach the native")
nui_callbacks["map:setWaypoint"]({ coords = { x = 10001, y = 0 } }, function(result)
marker_result = result
end)
assert(not marker_result.success and marker_result.error == "invalid_marker", "out-of-world waypoint must be rejected")
local skyride_result
nui_callbacks["skyride:get-player-coords"]({}, function(result)
skyride_result = result
end)
assert(skyride_result.success and skyride_result.data.coords.x == 12.5, "SkyRide must reuse the location owner")
print("Client location tests passed")
+98
View File
@@ -0,0 +1,98 @@
local event_handlers = {}
local nui_callbacks = {}
local nui_messages = {}
local phone_open = false
function AddEventHandler(event_name, handler)
event_handlers[event_name] = handler
end
function GetCurrentResourceName()
return "sky_phone"
end
function RegisterNUICallback(callback_name, handler)
nui_callbacks[callback_name] = handler
end
function SendNUIMessage(message)
nui_messages[#nui_messages + 1] = message
end
SkyPhoneApps = {
ValidateAppId = function(app_id)
return type(app_id) == "string" and app_id:match("^[a-z0-9][a-z0-9._-]+$") ~= nil
end,
}
SkyPhoneClient = {
GetState = function()
return { open = phone_open }
end,
}
dofile("sky_phone/source/client/navigation.lua")
local callback_result
nui_callbacks["navigation:state"]({
currentApp = "messages",
installedApps = { "messages", "camera", "custom-app" },
}, function(result)
callback_result = result
end)
assert(callback_result.success, "valid navigation state must be accepted")
assert(SkyPhoneNavigation.IsDataLoaded(), "accepted renderer state must mark navigation data loaded")
local closed_success, closed_error = SkyPhoneNavigation.Open("messages")
assert(not closed_success and closed_error == "phone_closed", "closed phones must reject navigation")
phone_open = true
assert(SkyPhoneNavigation.IsInstalled("messages"), "installed app lookup must use renderer state")
assert(not SkyPhoneNavigation.IsInstalled("mail"), "missing apps must not report installed")
assert(SkyPhoneNavigation.GetCurrent() == "messages", "current app lookup must preserve the renderer route")
assert(SkyPhoneNavigation.GetCurrent("messages"), "current app predicate must match")
assert(not SkyPhoneNavigation.GetCurrent("camera"), "current app predicate must reject another app")
local missing_success, missing_error = SkyPhoneNavigation.Open("mail")
assert(not missing_success and missing_error == "app_not_installed", "uninstalled apps must be rejected")
local open_success, open_error = SkyPhoneNavigation.Open("camera")
assert(open_success and open_error == nil, "installed apps must open")
assert(nui_messages[1].type == "navigation:open-app", "open must use the neutral NUI route")
assert(nui_messages[1].data.appId == "camera", "open must preserve the app id")
local wrong_close_success, wrong_close_error = SkyPhoneNavigation.Close("messages")
assert(not wrong_close_success and wrong_close_error == "app_not_active", "targeted close must match the active app")
local close_success, close_error = SkyPhoneNavigation.Close("camera")
assert(close_success and close_error == nil, "the active app must close")
assert(nui_messages[2].type == "navigation:close-app", "close must use the neutral NUI route")
assert(nui_messages[2].data.appId == "camera", "close must guard against stale router commands")
nui_callbacks["navigation:state"]({
currentApp = nil,
installedApps = { "messages" },
}, function(result)
callback_result = result
end)
assert(callback_result.success, "home navigation state must be accepted")
assert(SkyPhoneNavigation.GetCurrent() == "home", "an open phone without an app must report home")
phone_open = false
event_handlers["sky_phone:client:phoneToggled"](false)
local reset_state = SkyPhoneNavigation.GetState()
assert(reset_state.currentApp == nil and reset_state.installedApps.messages,
"phone close must preserve the loaded device app catalog")
assert(reset_state.dataLoaded and SkyPhoneNavigation.IsDataLoaded(),
"closing the UI must not make durable app data unavailable")
assert(SkyPhoneNavigation.GetCurrent() == nil, "a closed phone must not report a current app")
assert(not SkyPhoneNavigation.GetCurrent("messages"), "a closed phone must fail current app predicates")
nui_callbacks["navigation:state"]({
currentApp = false,
installedApps = { "messages" },
}, function(result)
callback_result = result
end)
assert(not callback_result.success and callback_result.error == "invalid_current_app", "invalid state must be rejected")
print("Client navigation tests passed")
+167
View File
@@ -0,0 +1,167 @@
local net_events = {}
local nui_messages = {}
local registered_apps = {
["custom-chat"] = true,
messages = true,
}
Config = {
Bridge = {
Locale = "en",
},
}
SkyPhoneApps = {
CanReceiveNotification = function(app_id)
return registered_apps[app_id] == true
end,
ValidateAppId = function(app_id)
return type(app_id) == "string"
and #app_id <= 64
and app_id:match("^[a-z0-9][a-z0-9._-]+$") ~= nil
end,
}
SkyPhoneLocales = {
Resolve = function()
return {
Nui = {
Apps = {},
},
}
end,
}
SkyPhoneNavigation = nil
Bridge = {
Callbacks = {
Trigger = function(name, notification)
assert(name == "sky_phone:notifications:self")
assert(notification.device == nil, "self notifications must not carry caller device data")
return { success = true, data = { delivered = 1 } }
end,
},
Debug = function() end,
Framework = {
Notify = function() end,
},
}
function RegisterNetEvent(name, callback)
net_events[name] = callback
end
function SendNUIMessage(message)
nui_messages[#nui_messages + 1] = message
end
function TriggerEvent()
end
assert(loadfile("sky_phone/source/shared/imei.lua"))()
assert(loadfile("sky_phone/source/client/notifications.lua"))()
local success, error_code = SkyPhoneNotifications.Show(nil)
assert(not success and error_code == "invalid_notification", "non-table notifications must be rejected")
success, error_code = SkyPhoneNotifications.Show({
appId = "messages",
app_id = "mail",
title = "Title",
text = "Text",
})
assert(not success and error_code == "invalid_app_id", "conflicting app aliases must be rejected")
success, error_code = SkyPhoneNotifications.Show({
appId = "missing-app",
title = "Title",
text = "Text",
})
assert(not success and error_code == "invalid_app_id", "unknown apps must not receive notifications")
success, error_code = SkyPhoneNotifications.Show({
appId = "messages",
title = string.rep("x", 161),
text = "Text",
})
assert(not success and error_code == "invalid_title", "notification titles must be bounded")
success, error_code = SkyPhoneNotifications.Show({
app_id = "custom-chat",
body = " Custom body ",
device = {
imei = "123456789012347",
name = "Spoofed",
},
title = " Custom title ",
url = "https://invalid.example",
})
assert(success, error_code)
assert(#nui_messages == 1, "valid local notifications must reach NUI while the phone is closed")
assert(nui_messages[1].type == "notification:show", "notification NUI route changed")
assert(nui_messages[1].data.appId == "custom-chat", "app_id alias was not normalized")
assert(nui_messages[1].data.route == "/apps/custom-chat", "safe app route was not generated")
assert(nui_messages[1].data.title == "Custom title", "title was not normalized")
assert(nui_messages[1].data.text == "Custom body", "body alias was not normalized")
assert(nui_messages[1].data.device == nil, "local callers must not inject a device context")
assert(nui_messages[1].data.url == nil, "arbitrary URLs must not cross the notification boundary")
local send_result
success, send_result = SkyPhoneNotifications.Send({
appId = "messages",
title = "Server-routed",
text = "Authoritative device",
})
assert(success and send_result.delivered == 1, "self notifications must use the server router")
assert(#nui_messages == 1, "self notifications must wait for authoritative server delivery")
source = 42
net_events["sky_phone:notifications:show"]({
appId = "messages",
device = {
imei = "123456789012347",
name = "Phone",
},
title = "Spoofed",
text = "Spoofed",
})
assert(#nui_messages == 1, "client-triggered delivery events must be rejected")
source = 65535
net_events["sky_phone:notifications:show"]({
appId = "messages",
title = "Missing device",
text = "Rejected",
})
assert(#nui_messages == 1, "server deliveries without a device must be rejected")
net_events["sky_phone:notifications:show"]({
appId = "messages",
device = {
imei = "123456789012347",
name = " Work Phone ",
settings = [[{"version":1}]],
},
title = "Server",
text = "Delivered",
})
assert(#nui_messages == 2, "server-originated delivery must reach NUI while navigation is empty")
local delivered = nui_messages[2].data
assert(delivered.device.imei == "123456789012347", "server IMEI context was dropped")
assert(delivered.device.name == "Work Phone", "server device name was not normalized")
assert(delivered.device.settings == [[{"version":1}]], "server device settings were dropped")
assert(delivered.route == "/apps/messages", "server notification route changed")
net_events["sky_phone:notifications:show"]({
appId = "messages",
device = {
imei = "invalid",
name = "Phone",
},
title = "Invalid",
text = "Rejected",
})
assert(#nui_messages == 2, "invalid server device contexts must be rejected")
print("Client notification router tests passed")
+51
View File
@@ -0,0 +1,51 @@
local callbacks = {}
local server_result = { success = true, data = { ok = true } }
local last_server_callback = nil
Bridge = {
Callbacks = {
Trigger = function(name, payload)
assert(type(payload) == "table", "NUI bridge must forward a table payload")
last_server_callback = name
return server_result
end,
},
Debug = function() end,
}
function RegisterNUICallback(name, callback)
assert(not callbacks[name], ("duplicate NUI callback %s"):format(name))
callbacks[name] = callback
end
dofile("sky_phone/source/client/nui_server_bridge.lua")
local callback_count = 0
for _ in pairs(callbacks) do
callback_count = callback_count + 1
end
assert(callback_count == 292, ("expected 292 NUI server callbacks, got %d"):format(callback_count))
for _, required in ipairs({
"companies:dial-service-line",
"mail:mailboxes",
"calls:set-speaker",
"media:import:commit",
"flare:delete-profile",
}) do
assert(type(callbacks[required]) == "function", ("missing callback %s"):format(required))
end
local invalid_result
callbacks["mail:list"]("invalid", function(result)
invalid_result = result
end)
assert(not invalid_result.success and invalid_result.error == "invalid_request", "invalid NUI payload must be rejected")
local forwarded_result
callbacks["mail:list"]({}, function(result)
forwarded_result = result
end)
assert(forwarded_result == server_result, "server response must be returned unchanged")
assert(last_server_callback == "sky_phone:mail:list", "NUI bridge callback name changed")
print("Client NUI server bridge tests passed")
+50
View File
@@ -0,0 +1,50 @@
local net_events = {}
local nui_callbacks = {}
local nui_messages = {}
local picker_focus = false
Bridge = {
Callbacks = {
Trigger = function(name)
assert(name == "sky_phone:sim:picker-close", "SIM picker must close through the server")
return { success = true }
end,
},
Debug = function() end,
}
SkyPhoneFocus = {
SetSimPicker = function(active)
picker_focus = active
end,
}
function RegisterNetEvent(name, callback)
net_events[name] = callback
end
function RegisterNUICallback(name, callback)
nui_callbacks[name] = callback
end
function SendNUIMessage(message)
nui_messages[#nui_messages + 1] = message
end
dofile("sky_phone/source/client/sim.lua")
net_events["sky_phone:sim:picker"]({ number = "5550101", choices = { "sim-1" } })
assert(picker_focus and nui_messages[#nui_messages].type == "sim:picker", "valid SIM picker must claim focus")
nui_messages = {}
SkyPhoneSimPicker.ReplayNui()
assert(nui_messages[1].type == "sim:picker", "NUI reload must replay the active SIM picker")
local close_result
nui_callbacks["sim:picker-close"]({}, function(result)
close_result = result
end)
assert(close_result.success and not picker_focus, "SIM picker close must release focus")
assert(nui_messages[#nui_messages].type == "sim:picker-close", "SIM picker close must reach NUI")
print("Client SIM picker tests passed")
+45 -2
View File
@@ -1,5 +1,38 @@
dofile("sky_phone/source/shared/custom_app_compat.lua")
dofile("sky_phone/source/bridge/phones/shared.lua")
assert(type(SkyPhoneCompatibility.RegisterExportAlias) == "function", "Shared core must expose export aliases")
assert(type(SkyPhoneCompatibility.EmitServerProviderStop) == "function", "Shared core must expose stop lifecycle")
assert(type(SkyPhoneCompatibility.EmitServerProviderStart) == "function", "Shared core must expose start lifecycle")
assert(type(SkyPhoneCompatibility.NormalizeAtResourceUrl) == "function", "Shared core must expose URL validation")
assert(SkyPhoneCompatibility.Providers.lb == "lb_phone", "Shared core must expose provider constants")
assert(SkyPhoneCompatibility.BuildLbDefinition == nil, "Shared core must not contain the LB mapper")
assert(SkyPhoneCompatibility.Build17MovDefinition == nil, "Shared core must not contain the 17mov mapper")
assert(SkyPhoneCompatibility.BuildHighDefinition == nil, "Shared core must not contain the High mapper")
assert(SkyPhoneCompatibility.BuildQuasarDefinition == nil, "Shared core must not contain the Quasar mapper")
assert(SkyPhoneCompatibility.BuildYSeriesDefinition == nil, "Shared core must not contain the YSeries mapper")
dofile("sky_phone/source/bridge/phones/shared/lb.lua")
assert(type(SkyPhoneCompatibility.BuildLbDefinition) == "function", "LB provider must expose its mapper")
assert(SkyPhoneCompatibility.Build17MovDefinition == nil, "LB provider must not expose the 17mov mapper")
dofile("sky_phone/source/bridge/phones/shared/seventeen.lua")
assert(type(SkyPhoneCompatibility.Build17MovDefinition) == "function", "17mov provider must expose its mapper")
assert(SkyPhoneCompatibility.BuildHighDefinition == nil, "17mov provider must not expose the High mapper")
dofile("sky_phone/source/bridge/phones/shared/high.lua")
assert(type(SkyPhoneCompatibility.BuildHighDefinition) == "function", "High provider must expose its mapper")
assert(SkyPhoneCompatibility.BuildQuasarDefinition == nil, "High provider must not expose the Quasar mapper")
dofile("sky_phone/source/bridge/phones/shared/quasar.lua")
assert(type(SkyPhoneCompatibility.CopyQuasarData) == "function", "Quasar provider must expose its copier")
assert(type(SkyPhoneCompatibility.BuildQuasarDefinition) == "function", "Quasar provider must expose its mapper")
assert(SkyPhoneCompatibility.BuildYSeriesDefinition == nil, "Quasar provider must not expose the YSeries mapper")
dofile("sky_phone/source/bridge/phones/shared/yseries.lua")
assert(type(SkyPhoneCompatibility.BuildYSeriesDefinition) == "function", "YSeries provider must expose its mapper")
local lb_on_open_calls = 0
local lb_on_use_calls = 0
local lb_definition = assert(SkyPhoneCompatibility.BuildLbDefinition("lb_app", {
identifier = "dispatch",
name = "Dispatch",
@@ -8,12 +41,22 @@ local lb_definition = assert(SkyPhoneCompatibility.BuildLbDefinition("lb_app", {
defaultApp = true,
fixBlur = true,
landscape = true,
onUse = function() end,
onOpen = function()
lb_on_open_calls = lb_on_open_calls + 1
end,
onUse = function()
lb_on_use_calls = lb_on_use_calls + 1
end,
onDelete = function() end,
}))
assert(lb_definition.id == "dispatch", "LB identifier must map to the Sky app ID")
assert(lb_definition.ui == "ui/index.html", "LB relative UI must remain owner-relative")
assert(lb_definition.orientation == "landscape", "LB landscape flag must be preserved")
assert(type(lb_definition.onOpen) == "function", "LB onUse must map to the open lifecycle")
lb_definition.onOpen()
assert(lb_on_open_calls == 1, "LB onOpen must run when the app opens")
assert(lb_on_use_calls == 1, "LB onUse must also run when the app opens")
assert(type(lb_definition.onDelete) == "function", "LB onDelete must map to the uninstall lifecycle")
assert(lb_definition.compatibility.resourceName == "lb_app", "LB callbacks must target the registering resource")
assert(lb_definition.compatibility.fixBlur, "LB fixBlur must be preserved")
+376 -23
View File
@@ -3,14 +3,20 @@ local registered_event_handlers = {}
local registered_nui_callbacks = {}
local triggered_events = {}
local nui_messages = {}
local collision_messages = {}
local registered_commands = {}
local invoking_resource = nil
Config = {
Bridge = {
Locale = "en",
},
Sim = {
NumberGroups = { 3, 4 },
NumberLength = 7,
NumberPrefix = "",
},
CustomApps = {
AllowRemoteOrigins = {},
BundledApps = false,
Debug = true,
Enabled = true,
@@ -65,7 +71,14 @@ end
function RegisterNUICallback(callback_name, handler)
registered_nui_callbacks[callback_name] = handler
end
function RegisterNetEvent() end
function RegisterCommand(command_name, handler)
registered_commands[command_name] = handler
end
function RegisterNetEvent(event_name, handler)
if handler then
AddEventHandler(event_name, handler)
end
end
function AddEventHandler(event_name, handler)
local handlers = registered_event_handlers[event_name] or {}
handlers[#handlers + 1] = handler
@@ -76,26 +89,129 @@ function SendNUIMessage(message)
end
function TriggerServerEvent() end
function TriggerEvent(event_name, ...)
local arguments = { ... }
triggered_events[#triggered_events + 1] = {
event_name = event_name,
arguments = { ... },
arguments = arguments,
}
if event_name:sub(1, 13) == "__cfx_export_"
or event_name == "sky_phone:client:customAppRemoved"
then
local handlers = registered_event_handlers[event_name] or {}
for index = 1, #handlers do
handlers[index](table.unpack(arguments))
end
end
end
function CreateThread(handler)
handler()
end
AddEventHandler("__cfx_export_lb-phone_AddCustomApp", function(set_callback)
set_callback(function()
return false, "App already exists"
end)
end)
dofile("sky_phone/source/bridge/shared.lua")
local bridge_debug = Bridge.Debug
Bridge.Debug = function(level, message, ...)
if level == "error" then
local arguments = { ... }
collision_messages[#collision_messages + 1] = #arguments > 0
and message:format(table.unpack(arguments))
or message
end
bridge_debug(level, message, ...)
end
dofile("sky_phone/source/shared/custom_apps.lua")
dofile("sky_phone/source/shared/custom_app_compat.lua")
dofile("sky_phone/source/bridge/phones/shared.lua")
for _, path in ipairs({
"sky_phone/source/bridge/phones/shared/lb.lua",
"sky_phone/source/bridge/phones/shared/seventeen.lua",
"sky_phone/source/bridge/phones/shared/high.lua",
"sky_phone/source/bridge/phones/shared/quasar.lua",
"sky_phone/source/bridge/phones/shared/yseries.lua",
}) do
dofile(path)
end
dofile("sky_phone/source/client/custom_apps.lua")
dofile("sky_phone/source/client/custom_app_compat.lua")
local custom_app_capabilities = SkyPhoneApps.ClientPublicApi.GetCustomAppCapabilities()
assert(custom_app_capabilities.enabled and custom_app_capabilities.externalApps)
assert(custom_app_capabilities.messageDispatch)
local has_send_app_message = false
for index = 1, #custom_app_capabilities.exports do
if custom_app_capabilities.exports[index] == "SendAppMessage" then
has_send_app_message = true
break
end
end
assert(has_send_app_message, "Creator capability discovery must expose SendAppMessage")
SkyPhoneClient = {
Dial = function() return true end,
FormatNumber = function(value) return value end,
GetState = function()
return { inCall = false, onScreen = false, open = false, phoneNumber = nil }
end,
GetEquippedPhoneNumber = function() return nil end,
OpenMessages = function() return true end,
Toggle = function() return true end,
}
SkyPhoneCalls = {
Dial = function() return true end,
}
SkyPhoneNotifications = {
Send = function(notification)
SendNUIMessage({
type = "notification:show",
data = {
appId = notification.appId,
route = "/apps/" .. notification.appId,
text = notification.text,
title = notification.title,
},
})
return true
end,
}
SkyPhoneCamera = {
DisableWalkable = function() end,
EnableWalkable = function() end,
GetState = function()
return { active = false, flashEnabled = false, selfie = false, walkable = false }
end,
SetFlashlight = function() end,
SetSelfie = function() end,
ToggleFrozen = function() end,
}
SkyPhoneNavigation = {
Close = function() return true end,
GetCurrent = function() return "home" end,
IsDataLoaded = function() return true end,
IsInstalled = function() return false end,
Open = function() return false end,
}
SkyPhoneFocus = {
SetExternalGameInput = function() return true end,
}
for _, path in ipairs({
"sky_phone/source/bridge/phones/client/core.lua",
"sky_phone/source/bridge/phones/client/lb.lua",
"sky_phone/source/bridge/phones/client/seventeen.lua",
"sky_phone/source/bridge/phones/client/high.lua",
"sky_phone/source/bridge/phones/client/quasar.lua",
"sky_phone/source/bridge/phones/client/yseries.lua",
"sky_phone/source/bridge/phones/client/lifecycle.lua",
}) do
dofile(path)
end
local function get_alias_export(resource_name, export_name)
local event_name = ("__cfx_export_%s_%s"):format(resource_name, export_name)
local handlers = registered_event_handlers[event_name]
assert(handlers and #handlers == 1, ("Missing %s:%s export alias"):format(resource_name, export_name))
assert(handlers and #handlers >= 1, ("Missing %s:%s export alias"):format(resource_name, export_name))
local alias_handler
local export_callback = setmetatable({
@@ -105,7 +221,7 @@ local function get_alias_export(resource_name, export_name)
alias_handler = handler
end,
})
handlers[1](export_callback)
handlers[#handlers](export_callback)
assert(type(alias_handler) == "function", ("Invalid %s:%s export alias"):format(resource_name, export_name))
return alias_handler
end
@@ -136,13 +252,20 @@ local quasar_add_custom_apps_batch = get_alias_export("qs-smartphone", "addCusto
local quasar_update_custom_app = get_alias_export("qs-smartphone", "updateCustomApp")
local quasar_remove_custom_app = get_alias_export("qs-smartphone", "removeCustomApp")
local quasar_get_custom_apps = get_alias_export("qs-smartphone", "getCustomApps")
local quasar_open_phone_app = get_alias_export("qs-smartphone", "OpenPhoneApp")
local yseries_add_custom_app = get_alias_export("yseries", "AddCustomApp")
local yseries_remove_custom_app = get_alias_export("yseries", "RemoveCustomApp")
local yseries_send_app_message = get_alias_export("yseries", "SendAppMessage")
local yseries_close_app = get_alias_export("yseries", "CloseApp")
local yseries_get_data_loaded = get_alias_export("yseries", "GetDataLoaded")
assert(registered_event_handlers["__cfx_export_17mov_Phone_OpenApp"], "17Movement must own its navigation OpenApp")
assert(registered_event_handlers["__cfx_export_17mov_Phone_CloseApp"], "17Movement must own its navigation CloseApp")
assert(registered_event_handlers["__cfx_export_qs-smartphone_OpenPhoneApp"], "Quasar must use neutral navigation")
assert(registered_event_handlers["__cfx_export_yseries_CloseApp"], "YSeries must own its navigation CloseApp")
assert(registered_exports.OpenApp == nil, "compatibility bridge must not publish a bare OpenApp export")
assert(registered_exports.CloseApp == nil, "compatibility bridge must not publish a bare CloseApp export")
assert(registered_exports.OpenPhoneApp == nil, "compatibility bridge must not publish a bare OpenPhoneApp export")
assert(type(registered_commands["phone:toggle"]) == "function", "Quasar phone:toggle command must be registered")
local expected_provider_resources = {
["lb-phone"] = true,
["17mov_Phone"] = true,
@@ -150,20 +273,34 @@ local expected_provider_resources = {
["qs-smartphone"] = true,
["yseries"] = true,
}
local resource_start_events = {}
assert(
collision_messages[1]
and collision_messages[1]:find("Compatibility collision: lb-phone:AddCustomApp has 2 providers", 1, true),
"concurrent original phone providers must emit an actionable compatibility error"
)
local provider_lifecycle_events = {}
for index = 1, #triggered_events do
local event = triggered_events[index]
local resource_name = event.arguments[1]
if event.event_name == "onResourceStart" then
resource_start_events[resource_name] = true
if expected_provider_resources[resource_name]
and (event.event_name == "onClientResourceStop"
or event.event_name == "onResourceStop"
or event.event_name == "onResourceStart")
then
local lifecycle = provider_lifecycle_events[resource_name] or {}
lifecycle[#lifecycle + 1] = event.event_name
provider_lifecycle_events[resource_name] = lifecycle
end
end
for resource_name in pairs(expected_provider_resources) do
assert(resource_start_events[resource_name], ("Missing %s provider start signal"):format(resource_name))
local lifecycle = assert(provider_lifecycle_events[resource_name])
assert(lifecycle[1] == "onClientResourceStop", ("Missing %s provider cache reset signal"):format(resource_name))
assert(lifecycle[2] == "onResourceStop", ("Missing %s provider stop signal"):format(resource_name))
assert(lifecycle[3] == "onResourceStart", ("Missing %s provider start signal"):format(resource_name))
end
assert(yseries_get_data_loaded(), "YSeries must see the compatibility provider as loaded")
invoking_resource = "sky_base"
invoking_resource = "provider_test"
assert(lb_send_notification({
app = "calendar",
title = "Hospital",
@@ -185,6 +322,7 @@ assert(not unknown_notification_success and unknown_notification_error == "app_n
invoking_resource = "lb_app"
local lifecycle_response_delivered = false
local install_hook_after_response = false
local delete_hook_after_response = false
local open_hook_after_response = false
local lb_definition = {
identifier = "dispatch",
@@ -197,6 +335,9 @@ local lb_definition = {
install_hook_after_response = lifecycle_response_delivered
error("vendor install failure")
end),
onDelete = make_cfx_function_reference("delete-hook", function()
delete_hook_after_response = lifecycle_response_delivered
end),
onOpen = make_cfx_function_reference("open-hook", function()
open_hook_after_response = lifecycle_response_delivered
error(5)
@@ -239,6 +380,13 @@ end)
assert(lifecycle_response.success, "a vendor install hook failure must not fail installation")
assert(install_hook_after_response, "a vendor install hook must run after the NUI response")
lifecycle_response_delivered = false
lifecycle_callback({ appId = "dispatch", event = "delete" }, function(response)
lifecycle_response = response
lifecycle_response_delivered = true
end)
assert(lifecycle_response.success, "a vendor delete hook must not fail uninstallation")
assert(delete_hook_after_response, "a vendor delete hook must run after the NUI response")
lifecycle_response_delivered = false
lifecycle_callback({ appId = "dispatch", event = "open" }, function(response)
lifecycle_response = response
lifecycle_response_delivered = true
@@ -249,6 +397,23 @@ lifecycle_callback({ appId = "dispatch", event = "ready" }, function(response)
lifecycle_response = response
end)
assert(lifecycle_response.success, "ready must complete after a failed vendor open hook")
local server_message_handler = assert(
registered_event_handlers["sky_phone:custom-app:message"][1],
"server custom app messages must have a client delivery handler"
)
source = 65535
server_message_handler("lb_app", "dispatch", { type = "server-refresh" })
local server_message = nui_messages[#nui_messages]
assert(server_message.type == "custom-app:message", "server app messages must reach the active frame")
assert(server_message.data.appId == "dispatch", "server app messages must preserve the app ID")
assert(server_message.data.payload.type == "server-refresh", "server app messages must preserve JSON data")
local message_count = #nui_messages
source = 42
server_message_handler("lb_app", "dispatch", { type = "spoofed" })
assert(#nui_messages == message_count, "client-triggered server app messages must be rejected")
source = nil
SkyPhoneApps.SetPhoneOpen(false)
invoking_resource = "another_app"
@@ -272,6 +437,35 @@ local inactive_close_success, inactive_close_error = lb_close_app({ app = "dispa
assert(not inactive_close_success and inactive_close_error == "app_not_active", "LB close alias must preserve app state checks")
assert(lb_remove_custom_app("dispatch"), "the LB owner must be able to remove its app")
assert(lb_add_custom_app({
identifier = "remote-dashboard",
name = "Remote Dashboard",
description = "Hosted dashboard",
ui = "https://apps.example.com/dashboard/index.html",
icon = "https://cdn.example.com/dashboard.png",
}), "secure remote LB assets must register without a manual origin allowlist")
SkyPhoneApps.SetPhoneOpen(true)
SkyPhoneApps.SendCatalog()
local remote_catalog = nui_messages[#nui_messages]
local remote_app
for index = 1, #remote_catalog.data.apps do
if remote_catalog.data.apps[index].id == "remote-dashboard" then
remote_app = remote_catalog.data.apps[index]
break
end
end
assert(remote_app, "the remote LB app must be present in the catalog")
assert(
remote_app.ui == "https://apps.example.com/dashboard/index.html",
"the registered HTTPS UI origin must be derived automatically"
)
assert(
remote_app.icon == "https://cdn.example.com/dashboard.png",
"the registered HTTPS icon origin must be derived automatically"
)
SkyPhoneApps.SetPhoneOpen(false)
assert(lb_remove_custom_app("remote-dashboard"), "the remote LB app must remain owner-removable")
invoking_resource = "phone_adapter"
assert(lb_add_custom_app({
identifier = "manufacturer-app",
@@ -312,17 +506,14 @@ assert(yseries_add_custom_app({
}), "YSeries AddCustomApp must be selected from the key field")
local yseries_message_success, yseries_message_error = yseries_send_app_message("slots", { type = "ping" })
assert(not yseries_message_success and yseries_message_error == "app_not_active", "YSeries message alias must preserve app state checks")
local yseries_close_success, yseries_close_error = yseries_close_app({ app = "slots" })
assert(not yseries_close_success and yseries_close_error == "app_not_active", "YSeries close alias must preserve app state checks")
assert(yseries_remove_custom_app("slots"), "YSeries RemoveCustomApp must use the provider alias")
local ambiguous_success, ambiguous_error = registered_exports.AddCustomApp({
id = "ambiguous",
identifier = "ambiguous",
name = "Ambiguous",
local native_success, native_error = SkyPhoneApps.ClientPublicApi.AddCustomApp({
identifier = "provider-only-shape",
name = "Provider-only shape",
ui = "ui/index.html",
})
assert(not ambiguous_success and ambiguous_error == "ambiguous_app_provider", "ambiguous schemas must fail")
assert(not native_success and native_error == "invalid_app_id", "native Sky exports must not route vendor schemas")
invoking_resource = "mov_app"
assert(mov_add_application({
@@ -346,6 +537,149 @@ assert(high_add_application("bankingv2", {
local high_message_success, high_message_error = high_send_app_nui("bankingv2", { type = "ping" })
assert(not high_message_success and high_message_error == "app_not_active", "High message alias must preserve app state checks")
local high_server_definition = assert(SkyPhoneCompatibility.BuildHighDefinition(
"high_server_owner",
"server-owned",
{ externalUrl = "@high_server_owner/ui/index.html" },
{ en = { label = "Server App", description = "Server-owned app" } }
))
source = 65535
assert(registered_event_handlers["sky_phone:compat:high:client:syncApplication"][1](
"high_server_owner",
high_server_definition,
1
) == nil)
invoking_resource = "high_server_owner"
local authority_update_success, authority_update_error =
SkyPhoneApps.ClientPublicApi.UpdateCustomApp(high_server_definition)
assert(not authority_update_success and authority_update_error == "app_authority_mismatch",
"direct client updates must not overwrite a server-authorized app")
local authority_remove_success, authority_remove_error =
SkyPhoneApps.ClientPublicApi.RemoveCustomApp("server-owned")
assert(not authority_remove_success and authority_remove_error == "app_authority_mismatch",
"direct client removal must not delete a server-authorized app")
invoking_resource = "high_conflicting_owner"
local high_conflict_success, high_conflict_error = high_add_application("server-owned", {
externalUrl = "@high_conflicting_owner/ui/index.html",
}, {
en = {
label = "Conflicting App",
description = "Must never be retained",
},
})
assert(not high_conflict_success and high_conflict_error == "duplicate_app_id",
"High client registrations must reject a conflicting server owner")
registered_event_handlers["sky_phone:compat:high:client:removeApplication"][1](
"high_server_owner",
"server-owned"
)
local retained_success, retained_error = high_send_app_nui("server-owned", { type = "ping" })
assert(not retained_success and retained_error == "app_not_found",
"rejected High registrations must not reappear after the server owner stops")
local high_server_only_definition = assert(SkyPhoneCompatibility.BuildHighDefinition(
"missing_resource",
"server-only",
{ externalUrl = "https://apps.example.com/server-only/index.html" },
{ en = { label = "Server Only", description = "Server-only resource app" } }
))
source = 42
registered_event_handlers["sky_phone:compat:high:client:syncApplication"][1](
"missing_resource",
high_server_only_definition,
1
)
assert(SkyPhoneCompatibilityClient.FindProviderApp("server-only") == nil,
"locally invoked High server syncs must be rejected")
source = 65535
registered_event_handlers["sky_phone:compat:high:client:syncApplication"][1](
"missing_resource",
high_server_only_definition,
1
)
local high_server_only_record = SkyPhoneCompatibilityClient.GetProviderApp(
"missing_resource",
"server-only",
{ [SkyPhoneCompatibility.Providers.high] = true }
)
assert(high_server_only_record,
"server-authorized High apps must not require a downloaded client resource")
registered_event_handlers["sky_phone:compat:high:client:removeApplication"][1](
"missing_resource",
"server-only"
)
assert(SkyPhoneCompatibilityClient.FindProviderApp("server-only") == nil,
"server-only High apps must follow the authoritative server lifecycle")
local high_owner_a_definition = assert(SkyPhoneCompatibility.BuildHighDefinition(
"high_owner_a",
"owner-pinned",
{ externalUrl = "https://apps.example.com/owner-a/index.html" },
{ en = { label = "Owner A", description = "First owner" } }
))
local high_owner_b_definition = assert(SkyPhoneCompatibility.BuildHighDefinition(
"high_owner_b",
"owner-pinned",
{ externalUrl = "https://apps.example.com/owner-b/index.html" },
{ en = { label = "Owner B", description = "Conflicting owner" } }
))
registered_event_handlers["sky_phone:compat:high:client:syncApplication"][1](
"high_owner_a",
high_owner_a_definition,
1
)
registered_event_handlers["sky_phone:compat:high:client:syncApplication"][1](
"high_owner_b",
high_owner_b_definition,
2
)
assert(SkyPhoneCompatibilityClient.GetProviderApp(
"high_owner_a",
"owner-pinned",
{ [SkyPhoneCompatibility.Providers.high] = true }
), "the first server-authorized High owner must remain pinned")
registered_event_handlers["sky_phone:compat:high:client:removeApplication"][1](
"high_owner_b",
"owner-pinned"
)
assert(SkyPhoneCompatibilityClient.FindProviderApp("owner-pinned"),
"a conflicting owner must not remove the pinned server app")
registered_event_handlers["sky_phone:compat:high:client:removeApplication"][1](
"high_owner_a",
"owner-pinned"
)
assert(SkyPhoneCompatibilityClient.FindProviderApp("owner-pinned") == nil)
local high_restart_definition = assert(SkyPhoneCompatibility.BuildHighDefinition(
"high_restart_owner",
"restartable-server-app",
{ externalUrl = "https://apps.example.com/restartable/index.html" },
{ en = { label = "Restartable", description = "Client restart lifecycle" } }
))
registered_event_handlers["sky_phone:compat:high:client:syncApplication"][1](
"high_restart_owner",
high_restart_definition,
1
)
for index = 1, #registered_event_handlers.onClientResourceStop do
registered_event_handlers.onClientResourceStop[index]("high_restart_owner")
end
assert(SkyPhoneCompatibilityClient.FindProviderApp("restartable-server-app") == nil,
"client owner stop must remove the local server-authorized projection")
for index = 1, #registered_event_handlers.onClientResourceStart do
registered_event_handlers.onClientResourceStart[index]("high_restart_owner")
end
assert(SkyPhoneCompatibilityClient.GetProviderApp(
"high_restart_owner",
"restartable-server-app",
{ [SkyPhoneCompatibility.Providers.high] = true }
), "client owner restart must restore the authoritative High server record")
registered_event_handlers["sky_phone:compat:high:client:removeApplication"][1](
"high_restart_owner",
"restartable-server-app"
)
source = nil
invoking_resource = "quasar_app"
assert(quasar_add_custom_app({
id = "services",
@@ -360,9 +694,28 @@ assert(#quasar_apps == 1 and quasar_apps[1].id == "services", "Quasar getCustomA
assert(quasar_update_custom_app("services", {
label = "City Services",
}), "Quasar updateCustomApp must update an owned app")
local quasar_open_success, quasar_open_error = quasar_open_phone_app("services")
assert(not quasar_open_success and quasar_open_error == "phone_closed", "Quasar open alias must preserve phone state checks")
assert(quasar_remove_custom_app("services"), "Quasar removeCustomApp must remove an owned app")
assert(quasar_add_custom_apps_batch({}), "Quasar batch alias must accept an empty batch")
local resource_stop_handlers = assert(registered_event_handlers["onResourceStop"])
for index = 1, #resource_stop_handlers do
resource_stop_handlers[index]("sky_phone")
end
local client_stop_events = {}
local resource_stop_events = {}
for index = 1, #triggered_events do
local event = triggered_events[index]
local resource_name = event.arguments[1]
if event.event_name == "onClientResourceStop" then
client_stop_events[resource_name] = true
elseif event.event_name == "onResourceStop" then
resource_stop_events[resource_name] = true
end
end
for resource_name in pairs(expected_provider_resources) do
assert(client_stop_events[resource_name], ("Missing %s provider client stop signal"):format(resource_name))
assert(resource_stop_events[resource_name], ("Missing %s provider stop signal"):format(resource_name))
end
print("Custom app compatibility client tests passed")
+390 -13
View File
@@ -1,8 +1,22 @@
local event_handlers = {}
local migration_callbacks = {}
local registered_event_handlers = {}
local registered_exports = {}
local sent_events = {}
local triggered_events = {}
local invoking_resource = nil
local seam_calls = {
equipped_number = {},
formatted_number = {},
source_from_number = {},
}
local directory_calls = {
online_by_identifier = {},
online_by_imei = {},
online_by_source = {},
stored_by_imei = {},
stored_by_phone_number = {},
}
exports = setmetatable({}, {
__call = function(_, export_name, handler)
@@ -40,21 +54,362 @@ function TriggerClientEvent(event_name, target, ...)
}
end
dofile("sky_phone/source/shared/custom_app_compat.lua")
dofile("sky_phone/source/server/custom_app_compat.lua")
function TriggerEvent(event_name, ...)
triggered_events[#triggered_events + 1] = {
arguments = { ... },
event_name = event_name,
}
end
local alias_handlers = registered_event_handlers["__cfx_export_high-phone_addApplication"]
assert(alias_handlers and #alias_handlers == 1, "Missing high-phone:addApplication export alias")
local high_add_application
local export_callback = setmetatable({
__cfx_functionReference = "test-export-callback",
}, {
__call = function(_, handler)
high_add_application = handler
dofile("sky_phone/source/shared/imei.lua")
dofile("sky_phone/source/bridge/shared.lua")
dofile("sky_phone/source/bridge/phones/shared.lua")
dofile("sky_phone/source/bridge/phones/shared/lb.lua")
dofile("sky_phone/source/bridge/phones/shared/seventeen.lua")
dofile("sky_phone/source/bridge/phones/shared/high.lua")
dofile("sky_phone/source/bridge/phones/shared/quasar.lua")
dofile("sky_phone/source/bridge/phones/shared/yseries.lua")
Bridge.Database = {
AfterMigration = function(_, callback)
migration_callbacks[#migration_callbacks + 1] = callback
end,
})
alias_handlers[1](export_callback)
assert(type(high_add_application) == "function", "Invalid high-phone:addApplication export alias")
}
local phone_core = {
FormatNumber = function(value)
seam_calls.formatted_number[#seam_calls.formatted_number + 1] = value
return "formatted:" .. value
end,
GetEquippedPhoneNumber = function(player)
seam_calls.equipped_number[#seam_calls.equipped_number + 1] = player
if player == 42 then
return "5550000042"
end
if player == 73 then
return "5550000073"
end
if player == "char1:phone-owner" then
return "5550000099"
end
return nil
end,
GetSourceFromNumber = function(phone_number)
seam_calls.source_from_number[#seam_calls.source_from_number + 1] = phone_number
if tostring(phone_number) == "5550000073" then
return 73
end
return nil
end,
}
SkyPhoneDeviceDirectory = {
GetOnlineByIdentifier = function(identifier)
directory_calls.online_by_identifier[#directory_calls.online_by_identifier + 1] = identifier
if identifier == "char1:directory-owner" then
return { source = 85 }
end
return nil, "player_unavailable"
end,
GetOnlineByImei = function(phone_imei)
directory_calls.online_by_imei[#directory_calls.online_by_imei + 1] = phone_imei
if phone_imei == "123456789012347" then
return { source = 84 }
end
return nil, "player_unavailable"
end,
GetOnlineBySource = function(player_source)
directory_calls.online_by_source[#directory_calls.online_by_source + 1] = player_source
if player_source == 88 then
return { imei = "523456789012343" }
end
return nil, "player_unavailable"
end,
GetStoredDeviceByImei = function(phone_imei)
directory_calls.stored_by_imei[#directory_calls.stored_by_imei + 1] = phone_imei
if phone_imei == "223456789012346" then
return { phoneNumber = "5550000086" }
end
return nil, "device_not_found"
end,
GetStoredDeviceByPhoneNumber = function(phone_number)
directory_calls.stored_by_phone_number[#directory_calls.stored_by_phone_number + 1]
= phone_number
if phone_number == "5550000087" then
return { imei = "423456789012344" }
end
return nil, "device_not_found"
end,
}
dofile("sky_phone/source/bridge/phones/server/core.lua")
dofile("sky_phone/source/bridge/phones/server/lb.lua")
dofile("sky_phone/source/bridge/phones/server/seventeen.lua")
dofile("sky_phone/source/bridge/phones/server/high.lua")
dofile("sky_phone/source/bridge/phones/server/quasar.lua")
dofile("sky_phone/source/bridge/phones/server/yseries.lua")
dofile("sky_phone/source/bridge/phones/server/lifecycle.lua")
assert(SkyPhone == nil, "all provider adapters must load before the phone core is ready")
assert(
SkyPhoneCompatibilityServer.Phone == nil,
"provider adapters must not cache the phone core before migration"
)
SkyPhone = phone_core
for index = 1, #migration_callbacks do
migration_callbacks[index]()
end
assert(
SkyPhoneCompatibilityServer.Phone == phone_core,
"all provider adapters must bind the migrated phone core"
)
local function get_alias_export(resource_name, export_name)
local alias_handlers = registered_event_handlers[
("__cfx_export_%s_%s"):format(resource_name, export_name)
]
assert(alias_handlers and #alias_handlers == 1, ("Missing %s:%s export alias"):format(
resource_name,
export_name
))
local export_handler
local export_callback = setmetatable({
__cfx_functionReference = "test-export-callback",
}, {
__call = function(_, handler)
export_handler = handler
end,
})
alias_handlers[1](export_callback)
assert(type(export_handler) == "function", ("Invalid %s:%s export alias"):format(
resource_name,
export_name
))
return export_handler
end
local function invoke_event_handlers(event_name, ...)
local handlers = registered_event_handlers[event_name]
assert(handlers and #handlers > 0, ("Missing %s event handler"):format(event_name))
for index = 1, #handlers do
handlers[index](...)
end
end
assert(triggered_events[1].event_name == "onServerResourceStop", "LB server export caches must be invalidated")
assert(triggered_events[1].arguments[1] == "lb-phone", "LB server stop must use the provided resource name")
assert(triggered_events[2].event_name == "onResourceStop", "LB generic stop listeners must be notified")
assert(triggered_events[3].event_name == "onServerResourceStart", "LB server start listeners must be notified")
assert(triggered_events[4].event_name == "onResourceStart", "LB generic start listeners must be notified")
local lifecycle_event_offset = #triggered_events
invoke_event_handlers("sky_phone:server:phoneNumberChanged", 42, "5550000042")
invoke_event_handlers("sky_phone:server:phoneNumberGenerated", 42, "5550000043")
invoke_event_handlers("sky_phone:server:factoryReset", 42, "5550000044")
invoke_event_handlers(
"sky_phone:server:galleryMediaDeleted",
42,
"5550000042",
"https://media.example/deleted.png"
)
assert(
triggered_events[lifecycle_event_offset + 1].event_name == "lb-phone:numberChanged",
"LB number-change observer event must be preserved"
)
assert(
triggered_events[lifecycle_event_offset + 2].event_name == "lb-phone:phoneNumberGenerated",
"LB number-generation observer event must be preserved"
)
assert(
triggered_events[lifecycle_event_offset + 3].event_name == "lb-phone:factoryReset",
"LB factory-reset observer event must be preserved"
)
assert(
triggered_events[lifecycle_event_offset + 4].event_name == "lb-phone:deletedFromGallery",
"LB gallery-deletion observer event must be preserved"
)
assert(
triggered_events[lifecycle_event_offset + 4].arguments[3]
== "https://media.example/deleted.png",
"LB gallery observer must preserve the deleted link"
)
local lb_get_equipped_number = get_alias_export("lb-phone", "GetEquippedPhoneNumber")
assert(
lb_get_equipped_number(42) == "5550000042",
"LB source lookup must use the equipped-number seam"
)
assert(
lb_get_equipped_number("char1:phone-owner") == "5550000099",
"LB identifier lookup must preserve its documented string contract"
)
assert(next(registered_exports) == nil, "Vendor exports must only be registered as provider aliases")
local seventeen_get_number_from_player = get_alias_export("17mov_Phone", "GetNumberFromPlayer")
local seventeen_get_number_from_identifier = get_alias_export(
"17mov_Phone",
"GetNumberFromIdentifier"
)
local seventeen_get_source_from_number = get_alias_export(
"17mov_Phone",
"GetPlayerSrcFromActiveNumber"
)
assert(
seventeen_get_number_from_player(42) == "5550000042",
"17Movement source lookup must use the equipped-number seam"
)
assert(
seventeen_get_number_from_identifier("char1:phone-owner") == "5550000099",
"17Movement identifier lookup must use the equipped-number seam"
)
assert(
seventeen_get_source_from_number("5550000073") == 73,
"17Movement active-number lookup must use the source seam"
)
local equipped_calls_before_invalid = #seam_calls.equipped_number
assert(seventeen_get_number_from_player("42") == nil, "17Movement must reject string sources")
assert(
seventeen_get_number_from_identifier(" ") == nil,
"17Movement must reject blank identifiers"
)
assert(
#seam_calls.equipped_number == equipped_calls_before_invalid,
"Invalid 17Movement identity arguments must not reach the phone core"
)
local source_calls_before_invalid = #seam_calls.source_from_number
assert(
seventeen_get_source_from_number({}) == nil,
"17Movement must reject non-scalar phone numbers"
)
assert(
#seam_calls.source_from_number == source_calls_before_invalid,
"Invalid 17Movement phone numbers must not reach the phone core"
)
local high_get_player_phone_number = get_alias_export("high-phone", "getPlayerPhoneNumber")
local high_format_number = get_alias_export("high-phone", "formatNumber")
assert(
high_get_player_phone_number(42) == "5550000042",
"High source lookup must use the equipped-number seam"
)
assert(
high_format_number("5550000042") == "formatted:5550000042",
"High number formatting must use the configured formatting seam"
)
local formatted_calls_before_invalid = #seam_calls.formatted_number
assert(high_format_number(5550000042) == nil, "High formatNumber must require a string")
assert(
#seam_calls.formatted_number == formatted_calls_before_invalid,
"Invalid High numbers must not reach the formatter"
)
local quasar_get_current_phone_number = get_alias_export(
"qs-smartphone",
"GetCurrentPhoneNumber"
)
assert(
quasar_get_current_phone_number(42) == "5550000042",
"Quasar source lookup must use the equipped-number seam"
)
assert(quasar_get_current_phone_number(0) == nil, "Quasar must reject invalid sources")
local yseries_get_source = get_alias_export("yseries", "GetPlayerSourceIdByPhoneNumber")
local yseries_get_number = get_alias_export("yseries", "GetPhoneNumberBySourceId")
local yseries_get_source_by_imei = get_alias_export(
"yseries",
"GetPlayerSourceIdByPhoneImei"
)
local yseries_get_source_by_identifier = get_alias_export(
"yseries",
"GetPlayerSourceIdByIdentifier"
)
local yseries_get_number_by_imei = get_alias_export("yseries", "GetPhoneNumberByImei")
local yseries_get_imei_by_number = get_alias_export(
"yseries",
"GetPhoneImeiByPhoneNumber"
)
local yseries_get_imei_by_source = get_alias_export("yseries", "GetPhoneImeiBySourceId")
assert(
yseries_get_source(5550000073) == 73,
"YSeries number lookup must accept its documented scalar number form"
)
assert(
yseries_get_number(73) == "5550000073",
"YSeries source lookup must use the equipped-number seam"
)
assert(yseries_get_source(math.huge) == nil, "YSeries must reject non-finite phone numbers")
assert(yseries_get_number(73.5) == nil, "YSeries must reject fractional sources")
assert(
yseries_get_source_by_imei("123456789012347") == 84,
"YSeries IMEI-to-source lookup must use the online device directory"
)
assert(
yseries_get_source_by_identifier(" char1:directory-owner ") == 85,
"YSeries identifier-to-source lookup must use the normalized online directory identity"
)
assert(
yseries_get_number_by_imei("223456789012346") == "5550000086",
"YSeries IMEI-to-number lookup must use the stored device directory"
)
assert(
yseries_get_imei_by_number("5550000087") == "423456789012344",
"YSeries number-to-IMEI lookup must use the stored device directory"
)
assert(
yseries_get_imei_by_source(88) == "523456789012343",
"YSeries source-to-IMEI lookup must use the online device directory"
)
assert(
yseries_get_source_by_imei("999999999999994") == nil,
"YSeries IMEI-to-source lookup must preserve a missing directory result as nil"
)
assert(
yseries_get_source_by_identifier("char1:missing") == nil,
"YSeries identifier-to-source lookup must preserve a missing directory result as nil"
)
assert(
yseries_get_number_by_imei("888888888888885") == nil,
"YSeries IMEI-to-number lookup must preserve a missing directory result as nil"
)
assert(
yseries_get_imei_by_number("5550000999") == nil,
"YSeries number-to-IMEI lookup must preserve a missing directory result as nil"
)
assert(
yseries_get_imei_by_source(999) == nil,
"YSeries source-to-IMEI lookup must preserve a missing directory result as nil"
)
local directory_calls_before_invalid = {
online_by_identifier = #directory_calls.online_by_identifier,
online_by_imei = #directory_calls.online_by_imei,
online_by_source = #directory_calls.online_by_source,
stored_by_imei = #directory_calls.stored_by_imei,
stored_by_phone_number = #directory_calls.stored_by_phone_number,
}
assert(yseries_get_source_by_imei(123456789012345) == nil, "YSeries must reject numeric IMEIs")
assert(
yseries_get_source_by_identifier(string.rep("x", 81)) == nil,
"YSeries must reject overlong identifiers"
)
assert(yseries_get_number_by_imei("invalid-imei") == nil, "YSeries must reject invalid IMEIs")
assert(
yseries_get_imei_by_number(5550000087) == nil,
"YSeries number-to-IMEI must require its documented string argument"
)
assert(yseries_get_imei_by_source(88.5) == nil, "YSeries must reject fractional sources")
assert(
#directory_calls.online_by_imei == directory_calls_before_invalid.online_by_imei
and #directory_calls.online_by_identifier
== directory_calls_before_invalid.online_by_identifier
and #directory_calls.stored_by_imei == directory_calls_before_invalid.stored_by_imei
and #directory_calls.stored_by_phone_number
== directory_calls_before_invalid.stored_by_phone_number
and #directory_calls.online_by_source == directory_calls_before_invalid.online_by_source,
"Invalid YSeries identity arguments must not reach the device directory"
)
local high_add_application = get_alias_export("high-phone", "addApplication")
invoking_resource = "high_server_app"
local add_success, add_error = high_add_application("bankingv2", {
@@ -83,4 +438,26 @@ assert(sent_events[2].event_name == "sky_phone:compat:high:client:replaceSnapsho
assert(sent_events[2].target == 42, "High snapshot must target only the requester")
assert(#sent_events[2].arguments[1] == 1, "High snapshot must include the registered app")
event_handlers["sky_phone:compat:high:server:requestSnapshot"]()
assert(#sent_events == 2, "High snapshot rate limiting must remain unchanged")
invoke_event_handlers("onResourceStop", "high_server_app")
assert(#sent_events == 3, "Stopping a High app owner must broadcast one removal")
assert(
sent_events[3].event_name == "sky_phone:compat:high:client:removeApplication",
"High owner cleanup must preserve the removal event"
)
assert(sent_events[3].arguments[2] == "bankingv2", "High owner cleanup must remove its app")
local shutdown_event_offset = #triggered_events
invoke_event_handlers("onResourceStop", "sky_phone")
assert(
triggered_events[shutdown_event_offset + 1].event_name == "onServerResourceStop",
"Sky Phone shutdown must invalidate the LB server cache"
)
assert(
triggered_events[shutdown_event_offset + 2].event_name == "onResourceStop",
"Sky Phone shutdown must notify LB generic stop listeners"
)
print("Custom app compatibility server tests passed")
+151
View File
@@ -0,0 +1,151 @@
local invoking_resource = "creator_resource"
local client_events = {}
local encoded_value
local notification_requests = {}
Config = {
CustomApps = {
BundledApps = false,
Enabled = true,
ExternalApps = true,
MaximumMessageBytes = 64,
TrustedAdapters = {},
},
}
json = {
decode = function(encoded)
if encoded:sub(1, 1) == '"' then
return encoded:sub(2, -2)
end
return encoded_value
end,
encode = function(value)
if type(value) == "function" then
error("unsupported")
end
if type(value) == "string" then
return '"' .. value .. '"'
end
encoded_value = value
return "{}"
end,
}
SkyPhoneNotifications = {
Send = function(target, notification)
notification_requests[#notification_requests + 1] = {
notification = notification,
target = target,
}
return { delivered = target.value == 10 and 1 or 0 }
end,
}
Bridge = {
Debug = function() end,
Framework = {
GetIdentifier = function(source)
return source == 10 and "license:online" or nil
end,
},
}
function AddEventHandler() end
function GetCurrentResourceName() return "sky_phone" end
function GetInvokingResource() return invoking_resource end
function GetResourceState() return "started" end
function TriggerClientEvent(name, target, ...)
client_events[#client_events + 1] = {
arguments = { ... },
name = name,
target = target,
}
end
assert(loadfile("sky_phone/source/shared/custom_apps.lua"))()
assert(loadfile("sky_phone/source/server/custom_apps.lua"))()
local api = assert(SkyPhoneApps.ServerPublicApi)
local capabilities = api.GetCustomAppCapabilities()
assert(capabilities.enabled and capabilities.externalApps)
assert(capabilities.messageDispatch and capabilities.notificationDispatch)
assert(api.AddCustomAppPolicy({
id = "creator-app",
permissions = { "app.open", "notifications" },
}))
assert(api.SendAppMessage(10, "creator-app", {
action = "refresh",
revision = 2,
}))
assert(#client_events == 1, "valid server app messages must emit exactly one client event")
assert(client_events[1].name == "sky_phone:custom-app:message")
assert(client_events[1].target == 10)
assert(client_events[1].arguments[1] == "creator_resource",
"the server must bind messages to the invoking owner resource")
assert(client_events[1].arguments[2] == "creator-app")
assert(client_events[1].arguments[3].action == "refresh")
local notification_success, notification_result = api.SendCustomAppNotification(
10,
"creator-app",
{
title = "Creator",
content = "Updated",
}
)
assert(notification_success and notification_result.delivered == 1)
assert(#notification_requests == 1)
assert(notification_requests[1].target.kind == "source")
assert(notification_requests[1].target.value == 10)
assert(notification_requests[1].notification.appId == "creator-app")
assert(notification_requests[1].notification.text == "Updated")
notification_success, notification_result = api.SendCustomAppNotification(
20,
"creator-app",
{
title = "Creator",
text = "Offline",
}
)
assert(not notification_success and notification_result == "device_not_equipped")
notification_success, notification_result = api.SendCustomAppNotification(
10,
"creator-app",
{
appId = "other-app",
title = "Creator",
text = "Spoofed",
}
)
assert(not notification_success and notification_result == "invalid_app_id")
local success, error_code = api.SendCustomAppMessage("10", "creator-app", {})
assert(not success and error_code == "invalid_source", "message targets must remain strictly typed")
success, error_code = api.SendCustomAppMessage(20, "creator-app", {})
assert(not success and error_code == "player_unavailable", "offline message targets must fail visibly")
success, error_code = api.SendCustomAppMessage(10, "creator-app", string.rep("x", 65))
assert(not success and error_code == "payload_too_large", "message payload limits must be server enforced")
success, error_code = api.SendCustomAppMessage(10, "creator-app", function() end)
assert(not success and error_code == "invalid_payload", "non-JSON message payloads must be rejected")
invoking_resource = "other_resource"
success, error_code = api.SendCustomAppMessage(10, "creator-app", {})
assert(not success and error_code == "app_owner_mismatch",
"a different resource must not message another owner's app")
assert(#client_events == 1, "rejected messages must not reach a client")
notification_success, notification_result = api.SendCustomAppNotification(
10,
"creator-app",
{ title = "Creator", text = "Spoofed" }
)
assert(not notification_success and notification_result == "app_owner_mismatch")
print("Custom app server messaging tests passed")
+386
View File
@@ -0,0 +1,386 @@
local alias_handlers = {}
local event_handlers = {}
local net_events = {}
local registered_commands = {}
local registered_exports = {}
local toggle_calls = {}
local flashlight_calls = {}
local selfie_calls = {}
local navigation_calls = {}
local focus_calls = {}
local dial_calls = {}
local terminate_calls = 0
local answer_calls = 0
local decline_calls = 0
local notification_calls = {}
local quasar_push_notifications = {}
local phone_state = {
inCall = false,
onScreen = true,
open = true,
}
Config = {
Sim = {
NumberGroups = { 3, 4 },
NumberLength = 7,
NumberPrefix = "",
},
}
Bridge = {
Debug = function() end,
}
SkyPhoneApps = {
CompatibilityCore = {
Add = function() return true end,
Close = function() return true end,
CloseActive = function() return true end,
Open = function() return true end,
Remove = function() return true end,
SendMessage = function() return true end,
Update = function() return true end,
},
Debug = function() end,
}
SkyPhoneClient = {
GetEquippedPhoneNumber = function()
return "5550101"
end,
GetState = function()
return phone_state
end,
Toggle = function(...)
toggle_calls[#toggle_calls + 1] = table.pack(...)
return true
end,
}
SkyPhoneCalls = {
Answer = function()
answer_calls = answer_calls + 1
return true
end,
Decline = function()
decline_calls = decline_calls + 1
return true
end,
Dial = function(phone_number)
dial_calls[#dial_calls + 1] = phone_number
if phone_number == "5559999" then
return false, "target_unavailable"
end
return true
end,
Terminate = function()
terminate_calls = terminate_calls + 1
return true
end,
}
SkyPhoneNotifications = {
Send = function(notification)
notification_calls[#notification_calls + 1] = notification
return true
end,
Show = function()
error("provider notifications must use the server-authoritative Send path")
end,
}
SkyPhoneCamera = {
DisableWalkable = function() end,
EnableWalkable = function() end,
GetState = function()
return {
active = false,
flashEnabled = flashlight_calls[#flashlight_calls] == true,
selfie = selfie_calls[#selfie_calls] == true,
walkable = false,
}
end,
SetFlashlight = function(enabled)
flashlight_calls[#flashlight_calls + 1] = enabled
end,
SetSelfie = function(enabled)
selfie_calls[#selfie_calls + 1] = enabled
end,
ToggleFrozen = function() end,
}
SkyPhoneNavigation = {
Close = function(app_id)
navigation_calls[#navigation_calls + 1] = { action = "close", appId = app_id }
return true
end,
GetCurrent = function(app_id)
if app_id ~= nil then
return app_id == "messages"
end
return "messages"
end,
IsInstalled = function(app_id)
return app_id == "messages"
end,
Open = function(app_id)
navigation_calls[#navigation_calls + 1] = { action = "open", appId = app_id }
if app_id == "messages" then
return true
end
return false, "app_not_installed"
end,
}
SkyPhoneFocus = {
SetExternalGameInput = function(owner_resource, allow_game_input)
focus_calls[#focus_calls + 1] = {
allow = allow_game_input,
owner = owner_resource,
}
return true
end,
}
exports = setmetatable({}, {
__call = function(_, export_name, handler)
registered_exports[export_name] = handler
end,
})
function GetCurrentResourceName()
return "sky_phone"
end
function GetInvokingResource()
return "provider_test"
end
function AddEventHandler(event_name, handler)
local handlers = event_handlers[event_name] or {}
handlers[#handlers + 1] = handler
event_handlers[event_name] = handlers
end
function RegisterNetEvent(event_name, handler)
net_events[event_name] = handler
end
function RegisterCommand(command_name, handler)
registered_commands[command_name] = handler
end
function TriggerEvent(event_name, ...)
local handlers = event_handlers[event_name] or {}
for index = 1, #handlers do
handlers[index](...)
end
end
function SendNUIMessage() end
dofile("sky_phone/source/shared/sim_number.lua")
dofile("sky_phone/source/bridge/phones/shared.lua")
for _, path in ipairs({
"sky_phone/source/bridge/phones/shared/lb.lua",
"sky_phone/source/bridge/phones/shared/seventeen.lua",
"sky_phone/source/bridge/phones/shared/high.lua",
"sky_phone/source/bridge/phones/shared/quasar.lua",
"sky_phone/source/bridge/phones/shared/yseries.lua",
}) do
dofile(path)
end
for _, path in ipairs({
"sky_phone/source/bridge/phones/client/core.lua",
"sky_phone/source/bridge/phones/client/lb.lua",
"sky_phone/source/bridge/phones/client/seventeen.lua",
"sky_phone/source/bridge/phones/client/high.lua",
"sky_phone/source/bridge/phones/client/quasar.lua",
"sky_phone/source/bridge/phones/client/yseries.lua",
}) do
dofile(path)
end
local function get_alias(resource_name, export_name)
local event_name = ("__cfx_export_%s_%s"):format(resource_name, export_name)
local handlers = assert(event_handlers[event_name], "missing alias event: " .. event_name)
local alias_handler
handlers[#handlers](function(handler)
alias_handler = handler
end)
alias_handlers[event_name] = assert(alias_handler, "missing alias handler: " .. event_name)
return alias_handler
end
local function assert_no_return(handler, ...)
assert(table.pack(handler(...)).n == 0, "documented no-return export leaked a return value")
end
local mov_open = get_alias("17mov_Phone", "OpenPhone")
local mov_close = get_alias("17mov_Phone", "ClosePhone")
local mov_is_open = get_alias("17mov_Phone", "IsPhoneOpen")
local mov_get_number = get_alias("17mov_Phone", "GetPlayerNumber")
local mov_toggle_flashlight = get_alias("17mov_Phone", "ToggleFlashlight")
local mov_get_flashlight = get_alias("17mov_Phone", "GetFlashlightState")
local mov_create_notification = get_alias("17mov_Phone", "CreateNotification")
local mov_open_app = get_alias("17mov_Phone", "OpenApp")
local mov_close_app = get_alias("17mov_Phone", "CloseApp")
assert_no_return(mov_open)
assert(toggle_calls[#toggle_calls].n == 1 and toggle_calls[#toggle_calls][1] == true)
assert_no_return(mov_close)
assert(toggle_calls[#toggle_calls].n == 1 and toggle_calls[#toggle_calls][1] == false)
assert(mov_is_open() == true)
assert(mov_get_number() == "5550101")
local flashlight_count = #flashlight_calls
assert_no_return(mov_toggle_flashlight, "true")
assert(#flashlight_calls == flashlight_count, "17Movement flashlight must reject non-booleans")
assert_no_return(mov_toggle_flashlight, true)
assert(flashlight_calls[#flashlight_calls] == true and mov_get_flashlight() == true)
assert_no_return(mov_create_notification, {
app = "MESSAGES",
message = "Hello",
title = "Message",
})
assert(notification_calls[#notification_calls].appId == "messages")
assert(notification_calls[#notification_calls].text == "Hello")
local notification_count = #notification_calls
assert_no_return(mov_create_notification, {
app = "MESSAGES",
message = { key = "Messages:NewMessage" },
title = "Message",
})
assert(#notification_calls == notification_count,
"17Movement locale-object notifications must remain visibly unsupported")
assert_no_return(mov_open_app, "messages")
assert(navigation_calls[#navigation_calls].action == "open" and navigation_calls[#navigation_calls].appId == "messages")
assert_no_return(mov_close_app, "messages")
assert(navigation_calls[#navigation_calls].action == "close" and navigation_calls[#navigation_calls].appId == "messages")
local high_close = get_alias("high-phone", "closePhone")
local high_start_call = get_alias("high-phone", "startCall")
local high_end_call = get_alias("high-phone", "endCall")
local high_send_notification = get_alias("high-phone", "sendNotification")
local high_set_facing = get_alias("high-phone", "setCameraFacing")
local high_format_number = get_alias("high-phone", "formatNumber")
local high_use_phone_item = get_alias("high-phone", "usePhoneItem")
assert_no_return(high_close)
assert(toggle_calls[#toggle_calls][1] == false)
assert_no_return(high_start_call, "5550101", false)
assert(dial_calls[#dial_calls] == "5550101")
local dial_count = #dial_calls
assert_no_return(high_start_call, "5550102", true)
assert(#dial_calls == dial_count, "High Phone video calls must be rejected")
assert_no_return(high_start_call, "5550102", "false")
assert(#dial_calls == dial_count, "High Phone must reject a non-boolean video flag")
assert_no_return(high_end_call)
assert(terminate_calls == 1)
assert_no_return(high_send_notification, {
application = { name = "twizzler" },
content = "World",
duration = 5000,
title = "Hello",
})
assert(notification_calls[#notification_calls].appId == "flare")
assert(notification_calls[#notification_calls].text == "World")
notification_count = #notification_calls
assert_no_return(high_send_notification, {
content = "No application",
})
assert(#notification_calls == notification_count,
"High notifications without an application remain a documented partial")
assert_no_return(high_set_facing, "front")
assert(selfie_calls[#selfie_calls] == true)
assert_no_return(high_set_facing, "rear")
assert(selfie_calls[#selfie_calls] == false)
local selfie_count = #selfie_calls
assert_no_return(high_set_facing, "side")
assert(#selfie_calls == selfie_count, "High Phone facing must reject unknown values")
assert(high_format_number("5550101") == "555 0101")
assert_no_return(high_use_phone_item, { ignored = true })
assert(toggle_calls[#toggle_calls][1] == true)
local quasar_is_open = get_alias("qs-smartphone", "IsPhoneOpen")
local quasar_call = get_alias("qs-smartphone", "call")
local quasar_open_app = get_alias("qs-smartphone", "OpenPhoneApp")
assert(quasar_is_open() == true)
local open_result = table.pack(quasar_open_app("messages"))
assert(open_result.n == 1 and open_result[1] == true, "Quasar OpenPhoneApp must return one boolean")
open_result = table.pack(quasar_open_app("missing"))
assert(open_result.n == 1 and open_result[1] == false, "Quasar OpenPhoneApp failure leaked an error")
local call_result = quasar_call("5550102", "audio")
assert(call_result.success == true and call_result.error == nil)
call_result = quasar_call("5559999", "audio")
assert(call_result.success == false and call_result.error == "target_unavailable")
dial_count = #dial_calls
call_result = quasar_call("5550103", "video")
assert(call_result.success == false and call_result.error == "video_unsupported")
assert(#dial_calls == dial_count, "Quasar video calls must be rejected before dialing")
call_result = quasar_call("5550103", "fax")
assert(call_result.success == false and call_result.error == "invalid_call_type")
assert(type(registered_commands["phone:toggle"]) == "function")
registered_commands["phone:toggle"]()
assert(toggle_calls[#toggle_calls].n == 0, "Quasar toggle command must use neutral toggle semantics")
assert(type(registered_commands["phone_peek_call_accept"]) == "function")
assert(type(registered_commands["phone_peek_call_reject"]) == "function")
registered_commands["phone_peek_call_accept"]()
registered_commands["phone_peek_call_reject"]()
assert(answer_calls == 1 and decline_calls == 1)
AddEventHandler("phone:pushNotification", function(notification)
quasar_push_notifications[#quasar_push_notifications + 1] = notification
end)
TriggerEvent("sky_phone:client:pushNotification", {
appId = "messages",
text = "Push",
title = "Quasar",
})
assert(#quasar_push_notifications == 1, "Quasar push notification event was not forwarded")
assert(quasar_push_notifications[1].appId == "messages")
assert(quasar_push_notifications[1].text == "Push")
local yseries_toggle = get_alias("yseries", "ToggleOpen")
local yseries_is_open = get_alias("yseries", "IsOpen")
local yseries_toggle_flashlight = get_alias("yseries", "ToggleFlashlight")
local yseries_get_flashlight = get_alias("yseries", "GetFlashlightState")
local yseries_close_app = get_alias("yseries", "CloseApp")
local yseries_is_app_installed = get_alias("yseries", "IsAppInstalled")
local yseries_get_current_app = get_alias("yseries", "GetCurrentAppId")
local yseries_set_focus = get_alias("yseries", "SetNuiFocusKeepInput")
local yseries_cancel_call = get_alias("yseries", "CancelCall")
assert_no_return(yseries_toggle, false)
assert(toggle_calls[#toggle_calls][1] == false and yseries_is_open() == true)
flashlight_count = #flashlight_calls
assert_no_return(yseries_toggle_flashlight, 1)
assert(#flashlight_calls == flashlight_count, "YSeries flashlight must reject non-booleans")
assert_no_return(yseries_toggle_flashlight, false)
assert(flashlight_calls[#flashlight_calls] == false and yseries_get_flashlight() == false)
assert_no_return(yseries_close_app)
assert(navigation_calls[#navigation_calls].action == "close" and navigation_calls[#navigation_calls].appId == nil)
assert(yseries_is_app_installed("messages") and not yseries_is_app_installed("mail"))
assert(yseries_get_current_app() == "messages")
assert(yseries_get_current_app("messages") and not yseries_get_current_app("mail"))
assert_no_return(yseries_set_focus, false)
assert(focus_calls[#focus_calls].owner == "provider_test" and focus_calls[#focus_calls].allow == false)
assert_no_return(yseries_cancel_call)
assert(terminate_calls == 2)
assert(next(registered_exports) == nil, "provider bridge must not publish bare vendor exports")
for _, path in ipairs({
"sky_phone/source/bridge/phones/client/core.lua",
"sky_phone/source/bridge/phones/client/lb.lua",
"sky_phone/source/bridge/phones/client/seventeen.lua",
"sky_phone/source/bridge/phones/client/high.lua",
"sky_phone/source/bridge/phones/client/quasar.lua",
"sky_phone/source/bridge/phones/client/yseries.lua",
"sky_phone/source/bridge/phones/client/lifecycle.lua",
}) do
local file = assert(io.open(path, "rb"))
local source = file:read("*a")
file:close()
assert(not source:match("[^%a]exports%s*%("), "bare exports() registration found in " .. path)
end
print("Provider client compatibility tests passed")
+292
View File
@@ -0,0 +1,292 @@
local event_handlers = {}
local migration_callbacks = {}
local ended_sources = {}
local notification_deliveries = {}
local active_calls = {
[10] = {
id = "550e8400-e29b-41d4-a716-446655440010",
caller = { source = 10, number = "5550101" },
callee = { source = 11, number = "5550111" },
startedAt = 1700000000,
anonymous = false,
video = false,
},
[20] = {
id = "550e8400-e29b-41d4-a716-446655440020",
caller = { source = 20, number = "5550102" },
callee = { source = 21, number = "5550121" },
startedAt = 1700000001,
anonymous = true,
companyId = "police",
video = false,
},
}
Bridge = {
Database = {
AfterMigration = function(name, callback)
assert(name == "sky_phone")
migration_callbacks[#migration_callbacks + 1] = callback
end,
},
Debug = function() end,
}
local phone_core = {
FormatNumber = function(phone_number)
return phone_number
end,
GetEquippedPhoneNumber = function(player_source)
return player_source == 10 and "5550101" or nil
end,
GetSourceFromNumber = function(phone_number)
return ({ ["5550101"] = 10, ["5550102"] = 20 })[tostring(phone_number)]
end,
}
SkyPhoneApps = {}
SkyPhoneCalls = nil
function AddEventHandler(event_name, handler)
local handlers = event_handlers[event_name] or {}
handlers[#handlers + 1] = handler
event_handlers[event_name] = handlers
end
function RegisterNetEvent(event_name, handler)
AddEventHandler(event_name, handler)
end
function GetCurrentResourceName()
return "sky_phone"
end
function GetInvokingResource()
return "provider_test"
end
function TriggerClientEvent()
end
dofile("sky_phone/source/bridge/phones/shared.lua")
dofile("sky_phone/source/bridge/phones/shared/seventeen.lua")
dofile("sky_phone/source/bridge/phones/shared/high.lua")
dofile("sky_phone/source/bridge/phones/server/core.lua")
dofile("sky_phone/source/bridge/phones/server/high.lua")
dofile("sky_phone/source/bridge/phones/server/quasar.lua")
dofile("sky_phone/source/bridge/phones/server/seventeen.lua")
assert(SkyPhone == nil, "provider bridge must load before the migrated phone core exists")
assert(
SkyPhoneCompatibilityServer.Phone == nil,
"provider bridge must not cache a phone service before migration"
)
SkyPhone = phone_core
for index = 1, #migration_callbacks do
migration_callbacks[index]()
end
assert(
SkyPhoneCompatibilityServer.Phone == phone_core,
"provider bridge must bind the migrated phone service"
)
SkyPhoneCalls = {
EndForSource = function(player_source)
ended_sources[#ended_sources + 1] = player_source
if not active_calls[player_source] then
return false, "call_not_found"
end
active_calls[player_source] = nil
return true
end,
GetById = function(call_id)
for _, call in pairs(active_calls) do
if call.id == call_id then
return call
end
end
return nil, "call_not_found"
end,
GetForSource = function(player_source)
local call = active_calls[player_source]
if not call then
return nil, "call_not_found"
end
return call
end,
IsActiveForSource = function(player_source)
return active_calls[player_source] ~= nil
end,
TerminateForSource = function(player_source)
ended_sources[#ended_sources + 1] = player_source
if not active_calls[player_source] then
return false, "call_not_found"
end
active_calls[player_source] = nil
return true
end,
}
SkyPhoneNotifications = {
Send = function(target, notification)
notification_deliveries[#notification_deliveries + 1] = {
notification = notification,
target = target,
}
return { delivered = target.kind == "all" and 2 or 1 }
end,
}
local function get_alias(resource_name, export_name)
local event_name = ("__cfx_export_%s_%s"):format(resource_name, export_name)
local handlers = assert(event_handlers[event_name], "missing alias event: " .. event_name)
local alias_handler
handlers[#handlers](function(handler)
alias_handler = handler
end)
return assert(alias_handler, "missing alias handler: " .. event_name)
end
local function assert_no_return(handler, ...)
assert(table.pack(handler(...)).n == 0, "documented no-return export leaked a return value")
end
local high_end_call = get_alias("high-phone", "endCall")
assert_no_return(high_end_call, 10)
assert(ended_sources[#ended_sources] == 10)
local ended_count = #ended_sources
assert_no_return(high_end_call, "10")
assert(#ended_sources == ended_count, "High Phone must reject string player sources")
active_calls[10] = {
id = "550e8400-e29b-41d4-a716-446655440011",
caller = { source = 10, number = "5550101" },
callee = { source = 11, number = "5550111" },
startedAt = 1700000002,
anonymous = false,
video = false,
}
local quasar_is_in_call = get_alias("qs-smartphone", "isPlayerInCall")
local quasar_end_call = get_alias("qs-smartphone", "endCallBySource")
assert(quasar_is_in_call(10) == true)
assert(quasar_is_in_call("10") == false)
assert_no_return(quasar_end_call, 10)
assert(quasar_is_in_call(10) == false)
ended_count = #ended_sources
assert_no_return(quasar_end_call, 0)
assert(#ended_sources == ended_count, "Quasar must reject invalid player sources")
active_calls[10] = {
id = "550e8400-e29b-41d4-a716-446655440012",
caller = { source = 10, number = "5550101" },
callee = { source = 11, number = "5550111" },
startedAt = 1700000003,
anonymous = false,
video = false,
}
active_calls[20] = {
id = "550e8400-e29b-41d4-a716-446655440020",
caller = { source = 20, number = "5550102" },
callee = { source = 21, number = "5550121" },
startedAt = 1700000001,
anonymous = true,
companyId = "police",
video = false,
}
local mov_end_by_source = get_alias("17mov_Phone", "PhoneApp_EndCallBySrc")
local mov_end_by_number = get_alias("17mov_Phone", "PhoneApp_EndCallByNumber")
local mov_is_in_call_by_source = get_alias("17mov_Phone", "PhoneApp_IsInCallBySrc")
local mov_is_in_call_by_number = get_alias("17mov_Phone", "PhoneApp_IsInCallByNumber")
local mov_get_id_by_source = get_alias("17mov_Phone", "PhoneApp_GetCallIdFromSrc")
local mov_get_id_by_number = get_alias("17mov_Phone", "PhoneApp_GetCallIdFromNumber")
local mov_get_data_by_source = get_alias("17mov_Phone", "PhoneApp_GetCallDataFromSrc")
local mov_get_data_by_number = get_alias("17mov_Phone", "PhoneApp_GetCallDataFromNumber")
local mov_get_data_by_id = get_alias("17mov_Phone", "PhoneApp_GetCallDataFromCallId")
assert(mov_is_in_call_by_source(10) == true)
assert(mov_is_in_call_by_source("10") == false)
assert(mov_is_in_call_by_number("5550102") == true)
assert(mov_is_in_call_by_number("5550999") == false)
assert(mov_get_id_by_source(10) == "550e8400-e29b-41d4-a716-446655440012")
assert(mov_get_id_by_source(30) == nil)
assert(mov_get_id_by_number("5550102") == "550e8400-e29b-41d4-a716-446655440020")
assert(mov_get_id_by_number("5550999") == nil)
local source_data = assert(mov_get_data_by_source(10))
assert(source_data.callId == "550e8400-e29b-41d4-a716-446655440012")
assert(source_data.fromId == 10 and source_data.fromNumber == "5550101")
assert(source_data.toId == 11 and source_data.toNumber == "5550111")
assert(source_data.callTime == 1700000003 and source_data.inCall == true)
assert(source_data.type == "phone" and source_data.isNumberHidden == false)
assert(source_data.isCompanyCall == false)
local number_data = assert(mov_get_data_by_number("5550102"))
assert(number_data.callId == "550e8400-e29b-41d4-a716-446655440020")
assert(number_data.isNumberHidden == true and number_data.isCompanyCall == true)
local id_data = assert(mov_get_data_by_id("550e8400-e29b-41d4-a716-446655440020"))
assert(id_data.callId == number_data.callId and id_data.fromId == 20 and id_data.toId == 21)
assert(mov_get_data_by_id("550e8400-e29b-41d4-a716-446655440099") == nil)
assert(mov_get_data_by_id("not-a-uuid") == nil)
assert(mov_end_by_number("5550102") == true)
assert(mov_end_by_number("5550102") == false)
assert(mov_end_by_source(10) == true)
assert(mov_end_by_source(10) == false)
assert(mov_end_by_source(0) == false)
local mov_notify_source = get_alias("17mov_Phone", "SendNotificationToSrc")
local mov_notify_number = get_alias("17mov_Phone", "SendNotificationToNumber")
local mov_notify_everyone = get_alias("17mov_Phone", "SendNotificationToEveryone")
local mov_notification = {
app = "BANK",
message = "Paid",
title = "Bank",
}
assert_no_return(mov_notify_source, 10, mov_notification)
assert(notification_deliveries[#notification_deliveries].target.kind == "source")
assert(notification_deliveries[#notification_deliveries].target.value == 10)
assert(notification_deliveries[#notification_deliveries].notification.appId == "banking")
assert_no_return(mov_notify_number, "5550102", mov_notification)
assert(notification_deliveries[#notification_deliveries].target.kind == "number")
assert(notification_deliveries[#notification_deliveries].target.value == "5550102")
assert_no_return(mov_notify_everyone, mov_notification)
assert(notification_deliveries[#notification_deliveries].target.kind == "all")
local notification_count = #notification_deliveries
assert_no_return(mov_notify_everyone, {
app = "SYSTEM",
message = { key = "System:Message" },
title = "System",
})
assert(#notification_deliveries == notification_count,
"17Movement locale-object notifications must remain a documented partial")
local high_notify = get_alias("high-phone", "sendNotification")
local high_notification = {
application = { name = "messages" },
content = "High",
duration = 5000,
title = "Phone",
}
assert_no_return(high_notify, -1, high_notification)
assert(notification_deliveries[#notification_deliveries].target.kind == "all")
assert_no_return(high_notify, 10, high_notification)
assert(notification_deliveries[#notification_deliveries].target.kind == "source")
assert(notification_deliveries[#notification_deliveries].target.value == 10)
assert_no_return(high_notify, "5550102", high_notification)
assert(notification_deliveries[#notification_deliveries].target.kind == "number")
assert(notification_deliveries[#notification_deliveries].notification.appId == "messages")
notification_count = #notification_deliveries
assert_no_return(high_notify, true, high_notification)
assert(#notification_deliveries == notification_count, "High must reject ambiguous receiver types")
local quasar_notify = get_alias("qs-smartphone", "sendPhoneNotification")
assert_no_return(quasar_notify, 10, {
appId = "messages",
text = "Ready",
title = "Order",
})
local quasar_delivery = notification_deliveries[#notification_deliveries]
assert(quasar_delivery.target.kind == "source" and quasar_delivery.target.value == 10)
assert(quasar_delivery.notification.appId == "messages")
assert(quasar_delivery.notification.text == "Ready")
print("Provider server compatibility tests passed")
+249
View File
@@ -0,0 +1,249 @@
local registered_exports = {}
local triggered_events = {}
local invoking_resource = "creator_resource"
local calls = {}
local event_handlers = {}
Config = {
CustomApps = {
Enabled = true,
ExternalApps = true,
},
Sim = {
NumberGroups = { 3, 4 },
NumberLength = 7,
NumberPrefix = "",
},
}
exports = function(name, handler)
registered_exports[name] = handler
end
function GetInvokingResource()
return invoking_resource
end
function AddEventHandler(name, handler)
event_handlers[name] = handler
end
function TriggerEvent(name, ...)
triggered_events[#triggered_events + 1] = { name = name, arguments = { ... } }
end
local function custom_app_handler(name)
return function(...)
calls.custom_app = { name = name, arguments = { ... } }
return true
end
end
SkyPhoneApps = {
ClientPublicApi = {
AddCustomApp = custom_app_handler("AddCustomApp"),
AddCustomAppFromAdapter = custom_app_handler("AddCustomAppFromAdapter"),
CloseActiveCustomAppFromAdapter = custom_app_handler("CloseActiveCustomAppFromAdapter"),
CloseCustomApp = custom_app_handler("CloseCustomApp"),
CloseCustomAppFromAdapter = custom_app_handler("CloseCustomAppFromAdapter"),
GetCustomAppCapabilities = custom_app_handler("GetCustomAppCapabilities"),
OpenCustomApp = custom_app_handler("OpenCustomApp"),
OpenCustomAppFromAdapter = custom_app_handler("OpenCustomAppFromAdapter"),
RemoveCustomApp = custom_app_handler("RemoveCustomApp"),
RemoveCustomAppFromAdapter = custom_app_handler("RemoveCustomAppFromAdapter"),
SendAppMessage = custom_app_handler("SendAppMessage"),
SendCustomAppMessage = custom_app_handler("SendCustomAppMessage"),
SendCustomAppMessageFromAdapter = custom_app_handler("SendCustomAppMessageFromAdapter"),
SendCustomAppNotification = custom_app_handler("SendCustomAppNotification"),
SendCustomAppNotificationFromAdapter = custom_app_handler(
"SendCustomAppNotificationFromAdapter"
),
UpdateCustomApp = custom_app_handler("UpdateCustomApp"),
UpdateCustomAppFromAdapter = custom_app_handler("UpdateCustomAppFromAdapter"),
},
ProtocolVersion = 1,
}
SkyPhoneClient = {
GetEquippedPhoneNumber = function()
return "1234567"
end,
GetState = function()
return { open = true }
end,
Toggle = function(open, no_focus)
calls.toggle = { open, no_focus }
return true
end,
}
SkyPhoneNavigation = {
Close = function(app_id)
calls.close_app = app_id
return true
end,
GetCurrent = function()
return "messages"
end,
GetState = function()
return { currentApp = "messages", installedApps = { messages = true } }
end,
IsDataLoaded = function()
return true
end,
IsInstalled = function(app_id)
return app_id == "messages"
end,
Open = function(app_id)
calls.open_app = app_id
return true
end,
}
SkyPhoneCalls = {
Answer = function() return true end,
Decline = function() return true end,
Dial = function(number, company)
calls.dial = { number, company }
return true
end,
GetActive = function() return { id = "call-id" } end,
Hangup = function() return true end,
IsActive = function() return true end,
Terminate = function() return true end,
}
SkyPhoneCamera = {
DisableWalkable = function()
calls.walkable = false
end,
EnableWalkable = function(selfie)
calls.walkable = true
calls.walkable_selfie = selfie
end,
GetState = function()
return { active = calls.walkable == true, frozen = calls.frozen == true }
end,
SetFlashlight = function(enabled)
calls.flashlight = enabled
end,
SetSelfie = function(enabled)
calls.selfie = enabled
end,
ToggleFrozen = function()
calls.frozen = not calls.frozen
end,
}
SkyPhoneFocus = {
SetExternalGameInput = function(owner, enabled)
calls.focus = { owner, enabled }
return true
end,
}
assert(loadfile("sky_phone/source/shared/imei.lua"))()
assert(loadfile("sky_phone/source/shared/sim_number.lua"))()
assert(loadfile("sky_phone/source/shared/public_api.lua"))()
assert(loadfile("sky_phone/source/client/public_api.lua"))()
assert(registered_exports.GetApiVersion() == "1.0.0")
assert(registered_exports.IsApiReady() == true)
assert(registered_exports.NormalizePhoneNumber("(123) 4567") == "1234567")
local invalid_number, invalid_number_error = registered_exports.NormalizePhoneNumber("123")
assert(invalid_number == nil and invalid_number_error == "invalid_phone_number")
assert(registered_exports.FormatPhoneNumber("1234567") == "123 4567")
local invalid_format, invalid_format_error = registered_exports.FormatPhoneNumber("123")
assert(invalid_format == nil and invalid_format_error == "invalid_phone_number")
assert(registered_exports.IsValidImei("123456789012347") == true)
local capabilities = registered_exports.GetApiCapabilities()
assert(capabilities.side == "client" and capabilities.features.calls.audio == true)
assert(capabilities.features.calls.video == false and capabilities.customAppProtocolVersion == 1)
assert(capabilities.features.customApps.enabled and capabilities.features.customApps.external)
assert(capabilities.features.notifications.customApps)
assert(capabilities.features.notifications.system == false)
capabilities.features.calls.audio = false
assert(registered_exports.GetApiCapabilities().features.calls.audio == true,
"capability results must be isolated copies")
assert(registered_exports.TogglePhone(true, true))
assert(calls.toggle[1] == true and calls.toggle[2] == true)
assert(registered_exports.GetPhoneState().open == true)
assert(registered_exports.GetEquippedPhoneNumber() == "1234567")
assert(registered_exports.AddCustomApp({ id = "creator-app" }))
assert(calls.custom_app.name == "AddCustomApp")
assert(registered_exports.SendAppMessage("creator-app", { type = "refresh" }))
assert(calls.custom_app.name == "SendAppMessage")
assert(calls.custom_app.arguments[1] == "creator-app")
assert(registered_exports.SendCustomAppMessage("creator-app", { type = "refresh" }))
assert(calls.custom_app.name == "SendCustomAppMessage")
assert(registered_exports.SendCustomAppNotification("creator-app", {
title = "Creator",
text = "Updated",
}))
assert(calls.custom_app.name == "SendCustomAppNotification")
assert(registered_exports.OpenApp("messages"))
assert(calls.open_app == "messages")
assert(registered_exports.CloseApp("messages"))
assert(calls.close_app == "messages")
assert(registered_exports.GetCurrentApp() == "messages")
assert(registered_exports.IsAppDataLoaded())
assert(registered_exports.IsAppInstalled("messages"))
assert(registered_exports.Dial("1234567"))
assert(calls.dial[1] == "1234567")
assert(registered_exports.AnswerCall())
assert(registered_exports.DeclineCall())
assert(registered_exports.HangupCall())
assert(registered_exports.TerminateCall())
assert(registered_exports.IsInCall())
assert(registered_exports.GetActiveCall().id == "call-id")
assert(registered_exports.SendNotification == nil,
"generic notifications must not bypass custom-app ownership")
local camera_success, camera_error = registered_exports.SetFlashlight("yes")
assert(not camera_success and camera_error == "invalid_state")
assert(calls.flashlight == nil)
camera_success, camera_error = registered_exports.SetCameraFrozen(true)
assert(not camera_success and camera_error == "camera_not_active")
camera_success, camera_error = registered_exports.ToggleCameraFrozen()
assert(not camera_success and camera_error == "camera_not_active")
invoking_resource = "other_resource"
assert(registered_exports.SetFlashlight(true),
"an inactive freeze request must not retain the camera claim")
assert(registered_exports.ReleaseCamera())
invoking_resource = "creator_resource"
assert(registered_exports.SetFlashlight(true))
assert(calls.flashlight == true)
assert(registered_exports.SetSelfieCamera(false))
assert(calls.selfie == false)
assert(registered_exports.EnableWalkableCamera(true))
assert(calls.walkable and calls.walkable_selfie)
local frozen_success, camera_state = registered_exports.SetCameraFrozen(true)
assert(frozen_success and camera_state.frozen == true)
frozen_success, camera_state = registered_exports.SetCameraFrozen(true)
assert(frozen_success and camera_state.frozen == true,
"SetCameraFrozen must be idempotent")
invoking_resource = "other_resource"
camera_success, camera_error = registered_exports.SetFlashlight(false)
assert(not camera_success and camera_error == "camera_claimed",
"another resource must not control a claimed camera")
invoking_resource = "creator_resource"
assert(registered_exports.DisableWalkableCamera())
assert(calls.walkable == false and calls.flashlight == false and calls.selfie == false)
assert(registered_exports.EnableWalkableCamera(false))
assert(type(event_handlers.onClientResourceStop) == "function")
event_handlers.onClientResourceStop("creator_resource")
assert(calls.walkable == false and calls.flashlight == false and calls.selfie == false,
"camera state must be released when its owner resource stops")
assert(registered_exports.SetPhoneGameInputEnabled(true))
assert(calls.focus[1] == "creator_resource" and calls.focus[2] == true)
invoking_resource = nil
local focus_success, focus_error = registered_exports.SetPhoneGameInputEnabled(false)
assert(not focus_success and focus_error == "resource_required")
assert(triggered_events[#triggered_events].name == "sky_phone:client:apiReady")
assert(triggered_events[#triggered_events].arguments[1] == "1.0.0")
print("Client public API tests passed")
+187
View File
@@ -0,0 +1,187 @@
local registered_exports = {}
local migration_callback
local triggered_events = {}
local service_calls = {}
Config = {
CustomApps = {
Enabled = true,
ExternalApps = true,
},
Sim = {
NumberGroups = { 3, 4 },
NumberLength = 7,
NumberPrefix = "",
},
}
local function custom_app_handler(name)
return function(...)
service_calls.custom_app = { name = name, arguments = { ... } }
return true
end
end
SkyPhoneApps = {
ServerPublicApi = {
AddCustomAppPolicy = custom_app_handler("AddCustomAppPolicy"),
AddCustomAppPolicyFromAdapter = custom_app_handler("AddCustomAppPolicyFromAdapter"),
GetCustomAppCapabilities = custom_app_handler("GetCustomAppCapabilities"),
GetCustomAppPolicy = custom_app_handler("GetCustomAppPolicy"),
HasCustomAppPermission = custom_app_handler("HasCustomAppPermission"),
RemoveCustomAppPolicy = custom_app_handler("RemoveCustomAppPolicy"),
RemoveCustomAppPolicyFromAdapter = custom_app_handler("RemoveCustomAppPolicyFromAdapter"),
SendAppMessage = custom_app_handler("SendAppMessage"),
SendCustomAppMessage = custom_app_handler("SendCustomAppMessage"),
SendCustomAppNotification = custom_app_handler("SendCustomAppNotification"),
UpdateCustomAppPolicy = custom_app_handler("UpdateCustomAppPolicy"),
UpdateCustomAppPolicyFromAdapter = custom_app_handler("UpdateCustomAppPolicyFromAdapter"),
},
}
exports = function(name, handler)
registered_exports[name] = handler
end
function TriggerEvent(name, ...)
triggered_events[#triggered_events + 1] = { name = name, arguments = { ... } }
end
Bridge = {
Database = {
AfterMigration = function(name, callback)
assert(name == "sky_phone")
migration_callback = callback
end,
},
Debug = function() end,
}
assert(loadfile("sky_phone/source/shared/imei.lua"))()
assert(loadfile("sky_phone/source/shared/sim_number.lua"))()
assert(loadfile("sky_phone/source/shared/public_api.lua"))()
assert(loadfile("sky_phone/source/server/public_api.lua"))()
assert(registered_exports.GetApiVersion() == "1.0.0")
local invalid_format, invalid_format_error = registered_exports.FormatPhoneNumber("123")
assert(invalid_format == nil and invalid_format_error == "invalid_phone_number")
assert(registered_exports.IsApiReady() == false)
local unavailable, unavailable_error = registered_exports.GetEquippedPhoneNumber(10)
assert(unavailable == nil and unavailable_error == "api_not_ready")
SkyPhone = {
GetEquippedPhoneNumber = function(player)
service_calls.player = player
return player == 10 and "1234567" or nil
end,
GetSourceFromNumber = function(number)
service_calls.number = number
return number == "1234567" and 10 or nil
end,
}
SkyPhoneDeviceDirectory = {
GetOnlineBySource = function(source)
return { source = source, imei = "123456789012347" }
end,
GetOnlineByPhoneNumber = function(number)
return { phoneNumber = number, source = 10 }
end,
GetOnlineByIdentifier = function(identifier)
return { identifier = identifier, source = 10 }
end,
GetOnlineByImei = function(imei)
return { imei = imei, source = 10 }
end,
GetStoredDeviceByImei = function(imei)
return { imei = imei, online = false }
end,
GetStoredDeviceByPhoneNumber = function(number)
return { phoneNumber = number, online = false }
end,
GetStoredDeviceByIdentifier = function(identifier)
return { identifier = identifier, online = false }
end,
GetStoredSimByPhoneNumber = function(number)
return { phoneNumber = number, simId = "sim-1" }
end,
}
SkyPhoneCalls = {
EndForSource = function(source)
service_calls.end_source = source
return true
end,
GetById = function(id)
return { id = id }
end,
GetForSource = function(source)
return { source = source }
end,
IsActiveForSource = function(source)
return source == 10
end,
TerminateForSource = function(source)
service_calls.terminate_source = source
return true
end,
}
SkyPhoneNotifications = {
Send = function(target, notification)
service_calls.notification = { target, notification }
return { delivered = 1 }
end,
}
assert(type(migration_callback) == "function")
migration_callback()
assert(registered_exports.IsApiReady() == true)
local capabilities = registered_exports.GetApiCapabilities()
assert(capabilities.ready and capabilities.side == "server")
assert(capabilities.features.deviceDirectory and capabilities.features.calls.video == false)
assert(capabilities.features.customApps.enabled and capabilities.features.customApps.external)
assert(capabilities.features.notifications.customApps)
assert(capabilities.features.notifications.system == false)
capabilities.features.deviceDirectory = false
assert(registered_exports.GetApiCapabilities().features.deviceDirectory == true,
"server capability results must be isolated copies")
assert(registered_exports.GetEquippedPhoneNumber(10) == "1234567")
assert(service_calls.player == 10)
assert(registered_exports.GetSourceFromPhoneNumber("1234567") == 10)
assert(service_calls.number == "1234567")
assert(registered_exports.AddCustomAppPolicy({ id = "creator-app" }))
assert(service_calls.custom_app.name == "AddCustomAppPolicy")
assert(registered_exports.SendAppMessage(10, "creator-app", { type = "refresh" }))
assert(service_calls.custom_app.name == "SendAppMessage")
assert(service_calls.custom_app.arguments[1] == 10)
assert(registered_exports.SendCustomAppMessage(10, "creator-app", { type = "refresh" }))
assert(service_calls.custom_app.name == "SendCustomAppMessage")
assert(registered_exports.SendCustomAppNotification(10, "creator-app", {
title = "Creator",
text = "Updated",
}))
assert(service_calls.custom_app.name == "SendCustomAppNotification")
assert(registered_exports.GetOnlineDeviceBySource(10).imei == "123456789012347")
assert(registered_exports.GetOnlineDeviceByPhoneNumber("1234567").source == 10)
assert(registered_exports.GetOnlineDeviceByIdentifier("license:test").source == 10)
assert(registered_exports.GetOnlineDeviceByImei("123456789012347").source == 10)
assert(registered_exports.GetStoredDeviceByImei("123456789012347").online == false)
assert(registered_exports.GetStoredDeviceByPhoneNumber("1234567").online == false)
assert(registered_exports.GetStoredDeviceByIdentifier("license:test").online == false)
assert(registered_exports.GetStoredSimByPhoneNumber("1234567").simId == "sim-1")
assert(registered_exports.GetActiveCallBySource(10).source == 10)
assert(registered_exports.GetActiveCallById("call-id").id == "call-id")
assert(registered_exports.IsPlayerInCall(10))
assert(registered_exports.EndCallForSource(10))
assert(service_calls.end_source == 10)
assert(registered_exports.TerminateCallForSource(10))
assert(service_calls.terminate_source == 10)
assert(registered_exports.SendNotification == nil,
"generic notifications must not bypass custom-app ownership")
assert(triggered_events[#triggered_events].name == "sky_phone:server:apiReady")
assert(triggered_events[#triggered_events].arguments[1] == "1.0.0")
print("Server public API tests passed")
+288
View File
@@ -0,0 +1,288 @@
local registered_callbacks = {}
local migration_callback = nil
local client_events = {}
local transactions = {}
local stopped_calls = {}
local speaker_enabled = true
Bridge = {
Callbacks = {
Register = function(name, callback)
assert(type(name) == "string" and type(callback) == "function")
registered_callbacks[name] = callback
end,
},
Calls = {
IsAvailable = function()
return true
end,
SetMuted = function()
return true
end,
SetSpeaker = function()
return true
end,
Start = function()
return true, "yaca"
end,
Stop = function(call_id, sources, provider)
stopped_calls[#stopped_calls + 1] = {
callId = call_id,
provider = provider,
sources = sources,
}
end,
SupportsMute = function()
return false
end,
SupportsSpeaker = function()
return false
end,
},
Database = {
AfterMigration = function(name, callback)
assert(name == "sky_phone")
migration_callback = callback
end,
Query = function()
return {}
end,
Transaction = function(operations)
transactions[#transactions + 1] = operations
return true
end,
},
Debug = function()
end,
Framework = {
GetPlayers = function()
return {}
end,
},
Speaker = {
IsEnabled = function()
return speaker_enabled
end,
},
}
Config = {
Calls = {
RecentPageSize = 50,
RingSeconds = 30,
},
Companies = {
CallRouting = {
MaxAttempts = 1,
RingSeconds = 30,
},
},
Mail = {
Domain = "test.local",
LocalPartMaxLength = 32,
LocalPartMinLength = 3,
},
Payphones = {
Animation = {
HangupDurationMs = 2000,
},
},
Sim = {
NumberLength = 7,
NumberPrefix = "",
},
}
SkyPhone = {}
SkyPhoneCompanies = {}
SkyPhoneSimNumber = {}
json = {
decode = function()
return {}
end,
}
function AddEventHandler()
end
function CreateThread()
end
function GetCurrentResourceName()
return "sky_phone"
end
function SetTimeout()
end
function TriggerClientEvent(name, target, payload)
client_events[#client_events + 1] = {
name = name,
payload = payload,
target = target,
}
end
assert(loadfile("sky_phone/source/server/calls.lua"))()
assert(type(migration_callback) == "function", "calls module must wait for its migration")
migration_callback()
local function find_upvalue(callback, expected_name)
for index = 1, 100 do
local name, value = debug.getupvalue(callback, index)
if not name then
break
end
if name == expected_name then
return value
end
end
error("missing upvalue: " .. expected_name)
end
local active_lookup = find_upvalue(SkyPhoneCalls.GetForSource, "active_call_for_source")
local active_by_source = find_upvalue(active_lookup, "active_by_source")
local calls = find_upvalue(active_lookup, "calls")
local function seed_call(call)
calls[call.id] = call
active_by_source[call.caller_source] = call.id
active_by_source[call.callee_source] = call.id
end
local call = {
id = "550e8400-e29b-41d4-a716-446655440000",
caller_number = "5550101",
caller_source = 10,
callee_number = "5550102",
callee_source = 20,
muted = {},
speakers = {},
started_at = 1000,
}
seed_call(call)
assert(SkyPhoneCalls.IsActiveForSource(10), "caller must be active while ringing")
assert(SkyPhoneCalls.IsActiveForSource(20), "callee must be active while ringing")
assert(not SkyPhoneCalls.IsActiveForSource("10"), "numeric strings must not be coerced into player sources")
assert(not SkyPhoneCalls.IsActiveForSource(10.5), "fractional player sources must be rejected")
local invalid_call, invalid_error = SkyPhoneCalls.GetForSource("10")
assert(invalid_call == nil and invalid_error == "invalid_source", "invalid source lookups must be explicit")
local caller_state = assert(SkyPhoneCalls.GetForSource(10))
assert(type(caller_state.id) == "string", "Sky call UUIDs must stay strings")
assert(caller_state.id == call.id and caller_state.state == "ringing", "ringing call state changed")
assert(caller_state.direction == "outgoing" and caller_state.otherNumber == "5550102", "caller view changed")
assert(caller_state.channel == nil, "ringing calls must not expose a voice channel")
assert(caller_state.caller.source == 10 and caller_state.caller.number == "5550101", "caller identity changed")
assert(caller_state.callee.source == 20 and caller_state.callee.number == "5550102", "callee identity changed")
assert(not caller_state.anonymous and not caller_state.video, "unsupported call modes must remain explicit")
assert(not caller_state.payphone and caller_state.companyId == nil, "ordinary call classification changed")
caller_state.state = "ended"
assert(SkyPhoneCalls.GetForSource(10).state == "ringing", "call lookups must return isolated snapshots")
local callee_state = assert(SkyPhoneCalls.GetForSource(20))
assert(callee_state.direction == "incoming" and callee_state.otherNumber == "5550101", "callee view changed")
local by_id_state = assert(SkyPhoneCalls.GetById(call.id))
assert(by_id_state.id == call.id and by_id_state.direction == "outgoing", "ID lookup must use a caller view")
by_id_state.caller.number = "changed"
assert(SkyPhoneCalls.GetById(call.id).caller.number == "5550101", "ID lookups must be isolated")
local invalid_id_call, invalid_id_error = SkyPhoneCalls.GetById("not-a-uuid")
assert(invalid_id_call == nil and invalid_id_error == "invalid_call_id", "invalid call IDs must be rejected")
call.answered_at = 1010
call.channel = 42
call.muted[10] = true
call.speakers[10] = true
call.voice_provider = "yaca"
call.voice_started = true
caller_state = assert(SkyPhoneCalls.GetForSource(10))
assert(caller_state.state == "connected" and caller_state.channel == 42, "connected call state changed")
assert(caller_state.muted and caller_state.muteSupported, "muted state must be projected for the caller")
assert(caller_state.speakerEnabled and caller_state.speakerSupported, "speaker state must be projected for the caller")
local ended, end_error = SkyPhoneCalls.EndForSource(0)
assert(not ended and end_error == "invalid_source", "invalid termination sources must be rejected")
assert(SkyPhoneCalls.EndForSource(10), "authoritative source termination must end the call")
assert(not SkyPhoneCalls.IsActiveForSource(10) and not SkyPhoneCalls.IsActiveForSource(20), "termination must clear participants")
assert(calls[call.id] == nil, "termination must remove the authoritative call")
local ended_id_call, ended_id_error = SkyPhoneCalls.GetById(call.id)
assert(ended_id_call == nil and ended_id_error == "call_not_found", "ended calls must not remain addressable")
assert(#stopped_calls == 1 and stopped_calls[1].callId == call.id, "termination must stop the voice backend")
assert(#transactions == 1, "termination must persist the completed call lifecycle")
assert(transactions[1][1].params[1] == "completed", "answered calls must remain completed")
assert(#client_events == 2, "both participants must receive the terminal state")
assert(client_events[1].payload.channel == nil and client_events[2].payload.channel == nil, "terminal states must drop the voice channel")
ended, end_error = SkyPhoneCalls.EndForSource(10)
assert(not ended and end_error == "call_not_found", "finished calls must not be ended twice")
local callback_call = {
id = "550e8400-e29b-41d4-a716-446655440001",
caller_number = "5550103",
caller_source = 30,
callee_number = "5550104",
callee_source = 40,
muted = {},
speakers = {},
started_at = 1100,
}
seed_call(callback_call)
local hangup_response = registered_callbacks["sky_phone:calls:hangup"](30, { id = callback_call.id })
assert(hangup_response.success, "the existing NUI hangup callback must retain its behavior")
assert(transactions[2][1].params[1] == "cancelled", "unanswered caller hangups must remain cancelled")
assert(transactions[2][3].params[1] == "missed", "unanswered callees must retain their missed status")
local company_call = {
id = "550e8400-e29b-41d4-a716-446655440002",
caller_number = "5550105",
caller_source = 50,
callee_number = "911",
callee_source = 60,
company_service_call = true,
muted = {},
speakers = {},
started_at = 1200,
}
seed_call(company_call)
assert(SkyPhoneCalls.TerminateForSource(60), "provider termination must end a routed company call")
assert(calls[company_call.id] == nil, "forced company-call termination must remove the call")
assert(not SkyPhoneCalls.IsActiveForSource(50) and not SkyPhoneCalls.IsActiveForSource(60),
"forced company-call termination must clear both participants")
assert(transactions[3][1].params[1] == "cancelled",
"forced unanswered company calls must remain cancelled")
local provider_call = {
id = "550e8400-e29b-41d4-a716-446655440003",
caller_number = "5550106",
caller_source = 70,
callee_number = "911",
callee_source = 80,
company_service_call = true,
muted = {},
speakers = {},
started_at = 1300,
}
seed_call(provider_call)
local terminate_response = registered_callbacks["sky_phone:calls:terminate"](
80,
{ id = provider_call.id }
)
assert(terminate_response.success, "provider termination callback must end the participant's call")
assert(calls[provider_call.id] == nil, "provider termination callback must bypass company rerouting")
assert(not SkyPhoneCalls.IsActiveForSource(70) and not SkyPhoneCalls.IsActiveForSource(80),
"provider termination callback must clear both participants")
local spoofed_terminate = registered_callbacks["sky_phone:calls:terminate"](
70,
{ id = "550e8400-e29b-41d4-a716-446655440099" }
)
assert(not spoofed_terminate.success and spoofed_terminate.error == "call_not_found",
"provider termination must revalidate the participant and call ID")
print("Server call seam tests passed")
+271
View File
@@ -0,0 +1,271 @@
local devices = {
["111111111111111"] = {
imei = "111111111111111",
device_name = "Phone A",
account_id = 11,
sim_id = "sim-a",
phone_number = "5550001",
sim_type = "registered",
sim_owner_identifier = "char:1",
},
["222222222222222"] = {
imei = "222222222222222",
device_name = "Phone B",
account_id = 11,
sim_id = "sim-b",
phone_number = "5550002",
sim_type = "registered",
sim_owner_identifier = "char:1",
},
["333333333333333"] = {
imei = "333333333333333",
device_name = "Phone C",
},
["444444444444444"] = {
imei = "444444444444444",
device_name = "Phone D",
},
["555555555555555"] = {
imei = "555555555555555",
device_name = "Phone E",
},
["666666666666666"] = {
imei = "666666666666666",
device_name = "Shared Phone",
sim_id = "sim-f",
phone_number = "5550005",
sim_type = "registered",
sim_owner_identifier = "char:5",
mapped_identifier = "char:5",
},
}
local sims = {
["5550001"] = {
id = "sim-a",
phone_number = "5550001",
sim_type = "registered",
owner_identifier = "char:1",
imei = "111111111111111",
},
["5550002"] = {
id = "sim-b",
phone_number = "5550002",
sim_type = "registered",
owner_identifier = "char:1",
imei = "222222222222222",
},
["5550005"] = {
id = "sim-f",
phone_number = "5550005",
sim_type = "registered",
owner_identifier = "char:5",
imei = "666666666666666",
},
["5550009"] = {
id = "sim-z",
phone_number = "5550009",
sim_type = "anonymous",
owner_identifier = "char:9",
},
}
local identifiers = {
[1] = "char:1",
[2] = "char:2",
[4] = "char:4",
[5] = "char:5",
}
local equipped_numbers = {
[1] = "5550002",
[2] = nil,
[4] = nil,
[5] = nil,
}
local source_by_number = {
["5550002"] = 1,
}
local inventory_slots = {
[1] = {
{ amount = 1, metadata = { imei = "111111111111111" }, slot = 1 },
{ amount = 1, metadata = { imei = "222222222222222" }, slot = 2 },
},
[2] = {
{ amount = 1, metadata = { imei = "333333333333333" }, slot = 1 },
},
[4] = {
{ amount = 1, metadata = { imei = "444444444444444" }, slot = 1 },
{ amount = 1, metadata = { imei = "555555555555555" }, slot = 2 },
},
[5] = {
{ amount = 1, metadata = {}, slot = 1 },
},
}
local function clone(row)
if not row then
return nil
end
local copied = {}
for key, value in pairs(row) do
copied[key] = value
end
return copied
end
Config = {
Phone = {
Item = "phone",
Unique = true,
},
Sim = {
NumberLength = 7,
NumberPrefix = "",
},
}
Bridge = {
Database = {
AfterMigration = function(name, callback)
assert(name == "sky_phone")
callback()
end,
Query = function(query, parameters)
local parameter = parameters[1]
if query:find("WHERE d.`imei` = ?", 1, true) then
local row = clone(devices[parameter])
return row and { row } or {}
end
if query:find("WHERE s.`phone_number` = ?", 1, true)
and query:find("FROM `sky_phone_devices` d", 1, true)
then
local sim = sims[parameter]
local row = sim and sim.imei and clone(devices[sim.imei]) or nil
return row and { row } or {}
end
if query:find("WHERE c.`owner_identifier` = ?", 1, true) then
for _, device in pairs(devices) do
if device.mapped_identifier == parameter then
return { clone(device) }
end
end
return {}
end
if query:find("FROM `sky_phone_sims` s", 1, true)
and query:find("WHERE s.`phone_number` = ?", 1, true)
then
local row = clone(sims[parameter])
return row and { row } or {}
end
error("unexpected directory query: " .. query)
end,
},
Debug = function() end,
Framework = {
GetIdentifier = function(source)
return identifiers[source]
end,
GetPlayers = function()
return { 1, 2, 4, 5 }
end,
},
Inventory = {
GetSlotsWithItem = function(source, item_name)
assert(item_name == "phone")
return inventory_slots[source] or {}
end,
},
}
SkyPhoneImei = {
IsValid = function(value)
return type(value) == "string" and #value == 15 and value:match("^%d+$") ~= nil
end,
}
dofile("sky_phone/source/shared/sim_number.lua")
SkyPhone = {
FindDeviceSlots = function(source, imei)
if source == 5 and imei == "666666666666666" then
return inventory_slots[source]
end
local matches = {}
for _, slot in ipairs(inventory_slots[source] or {}) do
if slot.metadata and slot.metadata.imei == imei then
matches[#matches + 1] = slot
end
end
return matches
end,
GetEquippedPhoneNumber = function(source)
return equipped_numbers[source]
end,
GetSourceFromNumber = function(phone_number)
return source_by_number[phone_number]
end,
}
dofile("sky_phone/source/server/device_directory.lua")
local source_identity = assert(SkyPhoneDeviceDirectory.GetOnlineBySource(1))
assert(source_identity.source == 1 and source_identity.identifier == "char:1")
assert(source_identity.imei == "222222222222222", "equipped number must select the preferred device, not the first slot")
assert(source_identity.phoneNumber == "5550002" and source_identity.equipped and source_identity.online)
local phone_identity = assert(SkyPhoneDeviceDirectory.GetOnlineByPhoneNumber("555-0002"))
assert(phone_identity.source == 1 and phone_identity.imei == "222222222222222")
local identifier_identity = assert(SkyPhoneDeviceDirectory.GetOnlineByIdentifier(" char:1 "))
assert(identifier_identity.source == 1 and identifier_identity.phoneNumber == "5550002")
local imei_identity = assert(SkyPhoneDeviceDirectory.GetOnlineByImei("222222222222222"))
assert(imei_identity.source == 1 and imei_identity.phoneNumber == "5550002")
local no_sim_identity = assert(SkyPhoneDeviceDirectory.GetOnlineBySource(2))
assert(no_sim_identity.imei == "333333333333333" and no_sim_identity.phoneNumber == nil)
assert(assert(SkyPhoneDeviceDirectory.GetOnlineByImei("333333333333333")).source == 2)
local ambiguous_identity, ambiguous_error = SkyPhoneDeviceDirectory.GetOnlineBySource(4)
assert(ambiguous_identity == nil and ambiguous_error == "equipped_device_ambiguous")
local stored_device = assert(SkyPhoneDeviceDirectory.GetStoredDeviceByPhoneNumber("5550001"))
assert(stored_device.imei == "111111111111111" and not stored_device.online and not stored_device.equipped)
local stored_sim = assert(SkyPhoneDeviceDirectory.GetStoredSimByPhoneNumber("5550009"))
assert(stored_sim.simId == "sim-z" and stored_sim.deviceImei == nil)
assert(stored_sim.registeredIdentifier == "char:9", "SIM registration identity must remain distinct")
local ejected_device, ejected_error = SkyPhoneDeviceDirectory.GetStoredDeviceByPhoneNumber("5550009")
assert(ejected_device == nil and ejected_error == "device_not_found")
local unique_identifier, unique_identifier_error = SkyPhoneDeviceDirectory.GetStoredDeviceByIdentifier("char:5")
assert(unique_identifier == nil and unique_identifier_error == "identity_scope_unsupported")
local invalid_source, invalid_source_error = SkyPhoneDeviceDirectory.GetOnlineBySource("1")
assert(invalid_source == nil and invalid_source_error == "invalid_source")
local infinite_source, infinite_source_error = SkyPhoneDeviceDirectory.GetOnlineBySource(math.huge)
assert(infinite_source == nil and infinite_source_error == "invalid_source")
local invalid_imei, invalid_imei_error = SkyPhoneDeviceDirectory.GetStoredDeviceByImei("123")
assert(invalid_imei == nil and invalid_imei_error == "invalid_imei")
equipped_numbers[1] = "5550001"
source_by_number["5550002"] = nil
source_by_number["5550001"] = 1
local refreshed_identity = assert(SkyPhoneDeviceDirectory.GetOnlineBySource(1))
assert(refreshed_identity.imei == "111111111111111", "online directory lookups must not cache equipped identity")
equipped_numbers[1] = "5550002"
source_by_number["5550001"] = nil
source_by_number["5550002"] = 1
Config.Phone.Unique = false
dofile("sky_phone/source/server/device_directory.lua")
local offline_shared = assert(SkyPhoneDeviceDirectory.GetStoredDeviceByIdentifier("char:5"))
assert(offline_shared.imei == "666666666666666" and offline_shared.identifier == "char:5")
local online_shared = assert(SkyPhoneDeviceDirectory.GetOnlineBySource(5))
assert(online_shared.imei == "666666666666666" and online_shared.identifier == "char:5")
assert(online_shared.phoneNumber == "5550005")
print("Server device directory tests passed")
+240
View File
@@ -0,0 +1,240 @@
local migration_callback
local client_events = {}
local registered_callbacks = {}
local players = {}
local source_lookup_counts = {}
local volatile_source
Config = {
Sim = {
NumberLength = 6,
NumberPrefix = "",
},
}
local online_by_source = {
[10] = {
imei = "123456789012347",
phoneNumber = "555010",
source = 10,
},
[30] = {
imei = "223456789012346",
phoneNumber = "555030",
source = 30,
},
}
Bridge = {
Callbacks = {
Register = function(name, handler)
registered_callbacks[name] = handler
end,
},
Database = {
AfterMigration = function(name, callback)
assert(name == "sky_phone")
migration_callback = callback
end,
Query = function(query, parameters)
assert(query:find("sky_phone_devices", 1, true), "device context query changed")
local imei = parameters[1]
local identities = {
["123456789012347"] = {
device_name = "Phone 10",
imei = "123456789012347",
settings = [[{"version":1,"source":10}]],
},
["223456789012346"] = {
device_name = "Phone 30",
imei = "223456789012346",
settings = [[{"version":1,"source":30}]],
},
["323456789012345"] = {
device_name = "Phone 40",
imei = "323456789012345",
settings = [[{"version":1,"source":40}]],
},
}
return identities[imei] and { identities[imei] } or {}
end,
},
Debug = function() end,
Framework = {
GetPlayers = function()
return players
end,
},
}
SkyPhoneApps = {
ValidateAppId = function(app_id)
return type(app_id) == "string"
and #app_id <= 64
and app_id:match("^[a-z0-9][a-z0-9._-]+$") ~= nil
end,
}
SkyPhoneDeviceDirectory = {
GetOnlineBySource = function(player_source)
source_lookup_counts[player_source] = (source_lookup_counts[player_source] or 0) + 1
if player_source == volatile_source and source_lookup_counts[player_source] >= 3 then
return nil, "device_not_equipped"
end
return online_by_source[player_source]
end,
GetOnlineByPhoneNumber = function(phone_number)
if phone_number == "555030" then
return online_by_source[30]
end
if phone_number == "555099" then
return {
imei = "223456789012346",
phoneNumber = "555099",
source = 30,
}
end
return nil, "player_unavailable"
end,
GetOnlineByImei = function(imei)
if imei == "123456789012347" then
return online_by_source[10]
end
return nil, "player_unavailable"
end,
}
function TriggerClientEvent(name, target, payload)
client_events[#client_events + 1] = {
name = name,
payload = payload,
target = target,
}
end
assert(loadfile("sky_phone/source/shared/imei.lua"))()
assert(loadfile("sky_phone/source/shared/sim_number.lua"))()
assert(loadfile("sky_phone/source/server/notifications.lua"))()
assert(type(migration_callback) == "function", "notification router must wait for migrations")
migration_callback()
local self_notification = assert(registered_callbacks["sky_phone:notifications:self"],
"self notification callback must be registered after migration")
local notification = {
app_id = "messages",
body = " Body ",
title = " Title ",
url = "https://invalid.example",
}
local result, error_code = SkyPhoneNotifications.Send(
{ kind = "source", value = "10" },
notification
)
assert(result == nil and error_code == "invalid_target", "source targets must remain strictly typed")
assert(#client_events == 0, "invalid targets must not deliver")
result, error_code = SkyPhoneNotifications.Send(
{ kind = "number", value = "not-a-number" },
notification
)
assert(result == nil and error_code == "invalid_target", "malformed numbers must be rejected")
result, error_code = SkyPhoneNotifications.Send(
{ kind = "device", value = "invalid-imei" },
notification
)
assert(result == nil and error_code == "invalid_target", "malformed IMEIs must be rejected")
result, error_code = SkyPhoneNotifications.Send(
{ kind = "source", value = 10 },
{
appId = "messages",
title = string.rep("x", 161),
text = "Body",
}
)
assert(result == nil and error_code == "invalid_title", "server payload bounds must be enforced")
source_lookup_counts = {}
result = assert(SkyPhoneNotifications.Send({ kind = "source", value = 10 }, notification))
assert(result.delivered == 1, "equipped source target must receive one notification")
assert(client_events[1].name == "sky_phone:notifications:show", "notification event name changed")
assert(client_events[1].target == 10, "notification source target changed")
assert(client_events[1].payload.appId == "messages", "app_id alias was not normalized")
assert(client_events[1].payload.title == "Title" and client_events[1].payload.text == "Body",
"notification text was not normalized")
assert(client_events[1].payload.url == nil, "unsafe fields must not cross the server boundary")
assert(client_events[1].payload.device.imei == "123456789012347",
"the final equipped IMEI must be attached")
assert(client_events[1].payload.device.name == "Phone 10",
"the final device name must be attached")
assert(client_events[1].payload.device.settings == [[{"version":1,"source":10}]],
"the final device settings must be attached")
assert(source_lookup_counts[10] == 3, "delivery must revalidate after loading device context")
source_lookup_counts = {}
local self_result = self_notification(10, notification)
assert(self_result.success and self_result.data.delivered == 1,
"self notifications must resolve the caller's equipped device")
assert(client_events[#client_events].payload.device.imei == "123456789012347",
"self notifications must carry the authoritative equipped IMEI")
client_events[#client_events] = nil
local unavailable_self_result = self_notification(20, notification)
assert(not unavailable_self_result.success and unavailable_self_result.error == "device_not_equipped",
"self notifications without an equipped device must fail visibly")
source_lookup_counts = {}
result = assert(SkyPhoneNotifications.Send({ kind = "source", value = 20 }, notification))
assert(result.delivered == 0, "players without an equipped device must not receive notifications")
assert(#client_events == 1, "no-delivery results must not emit an event")
source_lookup_counts = {}
result = assert(SkyPhoneNotifications.Send({ kind = "number", value = "555099" }, notification))
assert(result.delivered == 0, "changed phone-number ownership must fail revalidation")
assert(#client_events == 1, "failed number revalidation must not deliver")
source_lookup_counts = {}
result = assert(SkyPhoneNotifications.Send(
{ kind = "device", value = "123456789012347" },
notification
))
assert(result.delivered == 1, "equipped device targets must deliver")
assert(client_events[2].target == 10, "device target resolved to the wrong source")
assert(client_events[2].payload.device.imei == "123456789012347")
players = { 40 }
online_by_source[40] = {
imei = "323456789012345",
phoneNumber = "555040",
source = 40,
}
source_lookup_counts = {}
volatile_source = 40
result = assert(SkyPhoneNotifications.Send({ kind = "all" }, notification))
assert(result.delivered == 0, "a device switch during delivery must fail final revalidation")
assert(#client_events == 2, "stale broadcast candidates must not receive notifications")
players = { "10", 10, 20, 30, "30" }
source_lookup_counts = {}
volatile_source = nil
result = assert(SkyPhoneNotifications.Send({ kind = "all" }, notification))
assert(result.delivered == 2, "broadcast must deduplicate equipped online players")
assert(#client_events == 4, "broadcast count must match emitted events")
local broadcast_targets = {}
for index = 1, #client_events do
local event = client_events[index]
assert(event.target ~= -1, "notification broadcasts must never use blind -1 delivery")
if index >= 3 then
broadcast_targets[event.target] = (broadcast_targets[event.target] or 0) + 1
end
end
assert(broadcast_targets[10] == 1 and broadcast_targets[30] == 1,
"broadcast recipients must be unique and equipped")
result, error_code = SkyPhoneNotifications.Send({ kind = "all", value = true }, notification)
assert(result == nil and error_code == "invalid_target", "all targets must reject extra values")
print("Server notification router tests passed")
+195
View File
@@ -0,0 +1,195 @@
local registered_callbacks = {}
local migration_callbacks = {}
Bridge = {
Callbacks = {
Register = function(name, callback)
assert(type(name) == "string" and type(callback) == "function")
assert(registered_callbacks[name] == nil, "duplicate callback: " .. name)
registered_callbacks[name] = callback
end,
},
Database = {
AfterMigration = function(name, callback)
assert(name == "sky_phone")
migration_callbacks[#migration_callbacks + 1] = callback
end,
Query = function()
return {}
end,
Transaction = function()
return true
end,
},
Debug = function()
end,
Framework = {},
Inventory = {
GetResourceName = function()
return "test_inventory"
end,
RegisterUsableItem = function(_, callback)
assert(type(callback) == "function")
return true
end,
},
}
Config = {
Phone = {
DevelopmentCommand = false,
DeviceName = "Test Phone",
Item = "phone",
Unique = true,
},
Sim = {
Enabled = true,
NumberGroups = { 3, 4 },
NumberLength = 7,
NumberPrefix = "",
},
Server = {
PasscodePepper = "test-pepper",
},
Security = {
AttemptsPerMinute = 5,
LockSeconds = 60,
MaximumAttempts = 5,
},
Mail = {
AuthAttemptsPerMinute = 5,
Domain = "test.local",
LocalPartMinLength = 3,
LocalPartMaxLength = 32,
PasswordMinLength = 8,
PasswordMaxLength = 72,
},
}
json = {
encode = function()
return "{}"
end,
decode = function()
return {}
end,
}
function AddEventHandler(_, callback)
assert(type(callback) == "function")
end
function TriggerClientEvent()
end
function TriggerEvent()
end
function GetCurrentResourceName()
return "sky_phone"
end
function GetGameTimer()
return 1
end
function GetPlayers()
return {}
end
SkyPhoneImei = {}
SkyPhoneSimNumber = {}
local module_paths = {
"sky_phone/source/server/phone_security.lua",
"sky_phone/source/server/phone_accounts.lua",
"sky_phone/source/server/phone_persistence.lua",
"sky_phone/source/server/phone.lua",
}
for _, path in ipairs(module_paths) do
assert(loadfile(path))()
end
for _, callback in ipairs(migration_callbacks) do
callback()
end
assert(type(SkyPhoneSecurity) == "table")
assert(type(SkyPhoneSecurity.Load) == "function")
assert(type(SkyPhoneSecurity.Status) == "function")
assert(type(SkyPhoneAccounts) == "table")
assert(type(SkyPhoneAccounts.List) == "function")
for _, method in ipairs({
"EnsureDevice",
"FindDeviceSlots",
"LoadDevice",
"RefreshSource",
"GetEquippedPhoneNumber",
"GetSourceFromNumber",
"FormatNumber",
"RequireDeviceSession",
"RequireSession",
"AllowOperation",
"RequireAccount",
"NotifyAccount",
"NotifyAccountDevices",
"RefreshAccount",
"RefreshDevice",
"OpenDeviceForCall",
}) do
assert(type(SkyPhone[method]) == "function", "missing SkyPhone API: " .. method)
end
for _, callback_name in ipairs({
"sky_phone:security:unlock",
"sky_phone:security:set-passcode",
"sky_phone:security:change-passcode",
"sky_phone:security:disable-passcode",
"sky_phone:device:save",
"sky_phone:notifications:save",
"sky_phone:device:factory-reset",
"sky_phone:account:login",
"sky_phone:mail:login",
"sky_phone:account:register",
"sky_phone:mail:register",
"sky_phone:account:logout",
"sky_phone:mail:logout",
"sky_phone:account:devices",
"sky_phone:account:remove-device",
"sky_phone:device:open-request",
"sky_phone:device:development-open",
"sky_phone:device:close",
"sky_phone:device:equipped-number",
"sky_phone:device:notification-open",
}) do
assert(type(registered_callbacks[callback_name]) == "function", "missing callback: " .. callback_name)
end
for _, callback_name in ipairs({
"sky_phone:security:unlock",
"sky_phone:device:save",
"sky_phone:device:factory-reset",
"sky_phone:account:devices",
}) do
local response = registered_callbacks[callback_name](1, {})
assert(response.success == false and response.error == "device_not_open", "callback not bound to core: " .. callback_name)
end
local manifest_file = assert(io.open("sky_phone/fxmanifest.lua", "rb"))
local manifest = manifest_file:read("*a")
manifest_file:close()
local security_position = assert(manifest:find("source/server/phone_security.lua", 1, true))
local accounts_position = assert(manifest:find("source/server/phone_accounts.lua", 1, true))
local persistence_position = assert(manifest:find("source/server/phone_persistence.lua", 1, true))
local phone_position = assert(manifest:find("source/server/phone.lua", 1, true))
local migration_position = assert(manifest:find("source/server/db_migrate.lua", 1, true))
assert(security_position < phone_position)
assert(accounts_position < phone_position)
assert(persistence_position < phone_position)
assert(phone_position < migration_position)
print("server phone modules: ok")