mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
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:
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user