ADD - expand phone application platform

Add the Companies app, resilient call handling, custom app registry, vendor compatibility, storage policies, frontend bridge, tests, documentation, config, locale, and SQL migrations.
This commit is contained in:
Leon.Schmidt
2026-08-11 00:22:39 +02:00
parent 72e5f91e02
commit 42ac955052
77 changed files with 17630 additions and 333 deletions
@@ -0,0 +1,581 @@
local RESOURCE_NAME = GetCurrentResourceName()
local providers = SkyPhoneCompatibility.Providers
local core = SkyPhoneApps.CompatibilityCore
local provider_apps = {}
local high_client_apps = {}
local high_server_apps = {}
local function get_calling_resource(export_name)
local owner_resource = GetInvokingResource()
if owner_resource then
return owner_resource
end
print(("[%s] %s rejected: the export must be called by another resource."):format(
RESOURCE_NAME,
export_name
))
return nil, "invalid_owner"
end
local function copy_record_data(value)
if type(value) ~= "table" then
return nil
end
local copied = {}
for key, nested_value in pairs(value) do
copied[key] = nested_value
end
return copied
end
local function register_provider_app(provider, owner_resource, definition, vendor_data, update_existing)
local app_id = definition.id
local existing = provider_apps[app_id]
if existing and (existing.owner_resource ~= owner_resource or existing.provider ~= provider) then
return false, "duplicate_app_id"
end
local success, error_message
if existing and update_existing then
success, error_message = core.Update(owner_resource, definition)
else
success, error_message = core.Add(owner_resource, definition)
end
if success then
provider_apps[app_id] = {
definition = definition,
owner_resource = owner_resource,
provider = provider,
vendor_data = vendor_data,
}
end
return success, error_message
end
local function get_provider_app(owner_resource, app_id, allowed_providers)
if type(app_id) ~= "string" then
return nil, "invalid_app_id"
end
local record = provider_apps[app_id]
if not record then
return nil, "app_not_found"
end
if record.owner_resource ~= owner_resource then
return nil, "app_owner_mismatch"
end
if allowed_providers and not allowed_providers[record.provider] then
return nil, "app_provider_mismatch"
end
return record
end
local function remove_provider_app(owner_resource, app_id, allowed_providers)
local record, record_error = get_provider_app(owner_resource, app_id, allowed_providers)
if not record then
return false, record_error
end
return core.Remove(owner_resource, app_id)
end
function SkyPhoneApps.RegisterCompatibilityExport(owner_resource, app_data)
local provider
local definition
local definition_error
if app_data.identifier ~= nil then
provider = providers.lb
definition, definition_error = SkyPhoneCompatibility.BuildLbDefinition(owner_resource, app_data)
elseif app_data.key ~= nil then
provider = providers.yseries
definition, definition_error = SkyPhoneCompatibility.BuildYSeriesDefinition(app_data)
else
return false, "unknown_app_provider"
end
if not definition then
print(("[%s] %s registration rejected for %s: %s."):format(
RESOURCE_NAME,
provider,
owner_resource,
definition_error
))
return false, definition_error
end
return register_provider_app(provider, owner_resource, definition, copy_record_data(app_data), false)
end
SkyPhoneApps.OnCompatibilityAppRemoved = function(owner_resource, app_id)
local record = provider_apps[app_id]
if record and record.owner_resource == owner_resource then
provider_apps[app_id] = nil
end
end
local function add_17mov_application(app_data)
local owner_resource, owner_error = get_calling_resource("AddApplication")
if not owner_resource then
return false, owner_error
end
local definition, definition_error = SkyPhoneCompatibility.Build17MovDefinition(app_data)
if not definition then
print(("[%s] 17mov registration rejected for %s: %s."):format(
RESOURCE_NAME,
owner_resource,
definition_error
))
return false, definition_error
end
return register_provider_app(
providers.seventeen,
owner_resource,
definition,
copy_record_data(app_data),
false
)
end
local function remove_17mov_application(app_name, _resource_name)
local owner_resource, owner_error = get_calling_resource("RemoveApplication")
if not owner_resource then
return false, owner_error
end
local app_id = type(app_name) == "table" and app_name.name or app_name
return remove_provider_app(owner_resource, app_id, {
[providers.seventeen] = true,
})
end
local function send_app_message(app_id, data)
local owner_resource, owner_error = get_calling_resource("SendAppMessage")
if not owner_resource then
return false, owner_error
end
local record, record_error = get_provider_app(owner_resource, app_id, {
[providers.seventeen] = true,
[providers.yseries] = true,
})
if not record then
return false, record_error
end
return core.SendMessage(owner_resource, app_id, data)
end
local function add_high_application(app_name, data, locales)
local owner_resource, owner_error = get_calling_resource("addApplication")
if not owner_resource then
return false, owner_error
end
local definition, definition_error = SkyPhoneCompatibility.BuildHighDefinition(
owner_resource,
app_name,
data,
locales
)
if not definition then
print(("[%s] High Phone registration rejected for %s: %s."):format(
RESOURCE_NAME,
owner_resource,
definition_error
))
return false, definition_error
end
high_client_apps[app_name] = {
definition = definition,
owner_resource = owner_resource,
}
local server_record = high_server_apps[app_name]
if server_record then
if server_record.owner_resource ~= owner_resource then
return false, "duplicate_app_id"
end
if server_record.registered then
return true
end
return false, "server_definition_not_registered"
end
return register_provider_app(
providers.high,
owner_resource,
definition,
copy_record_data(data),
provider_apps[app_name] ~= nil
)
end
local function send_high_app_nui(app_name, data)
local owner_resource, owner_error = get_calling_resource("sendAppNui")
if not owner_resource then
return false, owner_error
end
local record, record_error = get_provider_app(owner_resource, app_name, {
[providers.high] = true,
})
if not record then
return false, record_error
end
return core.SendMessage(owner_resource, app_name, data)
end
local function add_quasar_app_for_owner(owner_resource, app_data)
local definition, definition_error = SkyPhoneCompatibility.BuildQuasarDefinition(app_data)
if not definition then
print(("[%s] Quasar registration rejected for %s: %s."):format(
RESOURCE_NAME,
owner_resource,
definition_error
))
return false, definition_error
end
return register_provider_app(
providers.quasar,
owner_resource,
definition,
SkyPhoneCompatibility.CopyQuasarData(app_data),
false
)
end
local function add_quasar_app(app_data)
local owner_resource, owner_error = get_calling_resource("addCustomApp")
if not owner_resource then
return false, owner_error
end
if type(app_data) ~= "table" then
return false, "invalid_app_data"
end
return add_quasar_app_for_owner(owner_resource, app_data)
end
local function add_quasar_apps_batch(apps)
local owner_resource, owner_error = get_calling_resource("addCustomAppsBatch")
if not owner_resource then
return false, owner_error
end
if type(apps) ~= "table" then
return false, "invalid_app_batch"
end
for index = 1, #apps do
local success, error_message = add_quasar_app_for_owner(owner_resource, apps[index])
if not success then
return false, error_message
end
end
return true
end
local function update_quasar_app(app_id, patch)
local owner_resource, owner_error = get_calling_resource("updateCustomApp")
if not owner_resource then
return false, owner_error
end
if type(patch) ~= "table" then
return false, "invalid_patch"
end
local record, record_error = get_provider_app(owner_resource, app_id, {
[providers.quasar] = true,
})
if not record then
return false, record_error
end
local merged = SkyPhoneCompatibility.CopyQuasarData(record.vendor_data)
for key, value in pairs(patch) do
if key == "iframe" and type(value) == "table" then
merged.iframe = merged.iframe or {}
for iframe_key, iframe_value in pairs(value) do
merged.iframe[iframe_key] = iframe_value
end
elseif key ~= "id" then
merged[key] = value
end
end
merged.id = app_id
local definition, definition_error = SkyPhoneCompatibility.BuildQuasarDefinition(merged)
if not definition then
return false, definition_error
end
local success, error_message = core.Update(owner_resource, definition)
if success then
record.definition = definition
record.vendor_data = SkyPhoneCompatibility.CopyQuasarData(merged)
end
return success, error_message
end
local function remove_quasar_app(app_id)
local owner_resource, owner_error = get_calling_resource("removeCustomApp")
if not owner_resource then
return false, owner_error
end
return remove_provider_app(owner_resource, app_id, {
[providers.quasar] = true,
})
end
local function get_quasar_apps()
local ids = {}
for app_id, record in pairs(provider_apps) do
if record.provider == providers.quasar then
ids[#ids + 1] = app_id
end
end
table.sort(ids)
local apps = {}
for index = 1, #ids do
apps[index] = SkyPhoneCompatibility.CopyQuasarData(provider_apps[ids[index]].vendor_data)
end
return apps
end
local function open_quasar_app(app_id)
local owner_resource, owner_error = get_calling_resource("OpenPhoneApp")
if not owner_resource then
return false, owner_error
end
local record, record_error = get_provider_app(owner_resource, app_id, {
[providers.quasar] = true,
})
if not record then
return false, record_error
end
return core.Open(owner_resource, app_id)
end
local function open_phone_app(app_id, data)
local owner_resource, owner_error = get_calling_resource("OpenApp")
if not owner_resource then
return false, owner_error
end
local record, record_error = get_provider_app(owner_resource, app_id)
if not record then
return false, record_error
end
return core.Open(owner_resource, app_id, data)
end
local function close_phone_app(options)
local owner_resource, owner_error = get_calling_resource("CloseApp")
if not owner_resource then
return false, owner_error
end
local app_id = type(options) == "table" and options.app or nil
if app_id then
local record, record_error = get_provider_app(owner_resource, app_id, {
[providers.lb] = true,
[providers.yseries] = true,
})
if not record then
return false, record_error
end
return core.Close(owner_resource, app_id)
end
return core.CloseActive(owner_resource)
end
local function register_high_server_app(owner_resource, definition, revision)
if type(owner_resource) ~= "string"
or type(definition) ~= "table"
or type(definition.id) ~= "string"
or type(revision) ~= "number"
or revision ~= math.floor(revision)
or revision < 1
then
print(("[%s] Rejected invalid High Phone server application snapshot."):format(RESOURCE_NAME))
return nil
end
local app_id = definition.id
local existing = high_server_apps[app_id]
if existing and revision <= existing.revision then
if revision < existing.revision or existing.registered then
return app_id
end
if existing.owner_resource ~= owner_resource then
print(("[%s] Rejected conflicting High Phone owner for %s."):format(
RESOURCE_NAME,
app_id
))
return app_id
end
end
local provider_record = provider_apps[app_id]
local success, error_message
local retained_registration = false
if provider_record then
if provider_record.owner_resource ~= owner_resource or provider_record.provider ~= providers.high then
success, error_message = false, "duplicate_app_id"
else
retained_registration = true
success, error_message = core.Update(owner_resource, definition)
end
else
success, error_message = core.Add(owner_resource, definition)
end
high_server_apps[app_id] = {
definition = definition,
last_error = success and nil or error_message,
owner_resource = owner_resource,
registered = success or retained_registration,
revision = revision,
}
if success then
provider_apps[app_id] = {
definition = definition,
owner_resource = owner_resource,
provider = providers.high,
}
elseif not existing or existing.revision ~= revision or existing.last_error ~= error_message then
print(("[%s] Could not register High Phone server application %s revision %s: %s."):format(
RESOURCE_NAME,
app_id,
revision,
error_message or "unknown_error"
))
end
return app_id
end
local function remove_high_server_app(owner_resource, app_id)
local record = high_server_apps[app_id]
if not record or record.owner_resource ~= owner_resource then
return
end
high_server_apps[app_id] = nil
if record.registered then
core.Remove(owner_resource, app_id)
end
local client_record = high_client_apps[app_id]
if client_record then
local success, error_message = register_provider_app(
providers.high,
client_record.owner_resource,
client_record.definition,
nil,
false
)
if not success then
print(("[%s] Could not restore High Phone client application %s: %s."):format(
RESOURCE_NAME,
app_id,
error_message or "unknown_error"
))
end
end
end
RegisterNetEvent("sky_phone:compat:high:client:syncApplication", function(owner_resource, definition, revision)
if source ~= 65535 then
print(("[%s] Rejected locally invoked High Phone application sync."):format(RESOURCE_NAME))
return
end
register_high_server_app(owner_resource, definition, revision)
end)
RegisterNetEvent("sky_phone:compat:high:client:removeApplication", function(owner_resource, app_id)
if source ~= 65535 then
print(("[%s] Rejected locally invoked High Phone application removal."):format(RESOURCE_NAME))
return
end
if type(owner_resource) ~= "string" or type(app_id) ~= "string" then
print(("[%s] Rejected invalid High Phone application removal."):format(RESOURCE_NAME))
return
end
remove_high_server_app(owner_resource, app_id)
end)
RegisterNetEvent("sky_phone:compat:high:client:replaceSnapshot", function(snapshot)
if source ~= 65535 then
print(("[%s] Rejected locally invoked High Phone application snapshot."):format(RESOURCE_NAME))
return
end
if type(snapshot) ~= "table" then
print(("[%s] Rejected invalid High Phone application snapshot."):format(RESOURCE_NAME))
return
end
local seen = {}
for index = 1, #snapshot do
local record = snapshot[index]
if type(record) == "table" then
local app_id = register_high_server_app(
record.owner_resource,
record.definition,
record.revision
)
if app_id then
seen[app_id] = true
end
end
end
local removed = {}
for app_id, record in pairs(high_server_apps) do
if not seen[app_id] then
removed[#removed + 1] = {
app_id = app_id,
owner_resource = record.owner_resource,
}
end
end
for index = 1, #removed do
remove_high_server_app(removed[index].owner_resource, removed[index].app_id)
end
end)
AddEventHandler("onClientResourceStart", function(resource_name)
for _, record in pairs(high_server_apps) do
if record.owner_resource == resource_name and not record.registered then
register_high_server_app(record.owner_resource, record.definition, record.revision)
end
end
end)
AddEventHandler("onClientResourceStop", function(resource_name)
for app_id, record in pairs(high_client_apps) do
if record.owner_resource == resource_name then
high_client_apps[app_id] = nil
end
end
end)
CreateThread(function()
TriggerServerEvent("sky_phone:compat:high:server:requestSnapshot")
TriggerEvent("17mov_Phone:Client:Ready")
end)
exports("AddApplication", add_17mov_application)
exports("RemoveApplication", remove_17mov_application)
exports("SendAppMessage", send_app_message)
exports("addApplication", add_high_application)
exports("sendAppNui", send_high_app_nui)
exports("addCustomApp", add_quasar_app)
exports("addCustomAppsBatch", add_quasar_apps_batch)
exports("updateCustomApp", update_quasar_app)
exports("removeCustomApp", remove_quasar_app)
exports("getCustomApps", get_quasar_apps)
exports("OpenPhoneApp", open_quasar_app)
exports("OpenApp", open_phone_app)
exports("CloseApp", close_phone_app)
File diff suppressed because it is too large Load Diff
+39
View File
@@ -13,7 +13,10 @@ local server_callbacks = {
"security:change-passcode",
"security:disable-passcode",
"device:save",
"device:notification-open",
"notifications:save",
"custom-app:storage:get",
"custom-app:storage:set",
"device:factory-reset",
"account:login",
"account:register",
@@ -166,6 +169,26 @@ local server_callbacks = {
"crewlink:create-ping",
"crewlink:remove-ping",
"crewlink:live",
"companies:list",
"companies:get",
"companies:my-requests",
"companies:get-request",
"companies:work-context",
"companies:work-queue",
"companies:list-members",
"companies:create-request",
"companies:cancel-request",
"companies:send-message",
"companies:claim-request",
"companies:assign-request",
"companies:update-request-status",
"companies:update-availability",
"companies:update-profile",
"companies:update-hours",
"companies:update-services",
"companies:publish-announcement",
"companies:set-call-availability",
"companies:call-customer",
"sim:insert",
"sim:eject",
"contacts:list",
@@ -228,6 +251,7 @@ local function send_open_message()
local payload = device_payload
payload.lang = Config.Bridge.Locale
payload.locales = get_locale().Nui
SkyPhoneApps.SendCatalog()
SendNUIMessage({
type = "app:open",
data = payload,
@@ -250,6 +274,7 @@ local function close_phone()
end
is_open = false
SkyPhoneApps.SetPhoneOpen(false)
TriggerEvent("sky_phone:nuiClosed")
SetNuiFocus(notification_focus or sim_picker_open, notification_focus or sim_picker_open)
SendNUIMessage({ type = "app:close" })
@@ -293,6 +318,7 @@ end
RegisterNUICallback("ui:ready", function(_, cb)
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
TriggerEvent("sky_phone:client:nuiReady")
SkyPhoneApps.SendCatalog()
if open_requested and device_payload then
send_open_message()
end
@@ -312,6 +338,7 @@ RegisterNUICallback("ui:opened", function(_, cb)
end
is_open = true
SkyPhoneApps.SetPhoneOpen(true)
notification_focus = false
SetNuiFocus(true, true)
TriggerEvent("sky_phone:animation:phone", true)
@@ -517,6 +544,18 @@ RegisterNetEvent("sky_phone:marketplace:changed", function(data)
SendNUIMessage({ type = "marketplace:changed", data = data })
end)
RegisterNetEvent("sky_phone:companies:changed", function(data)
SendNUIMessage({ type = "companies:changed", data = data })
end)
RegisterNetEvent("sky_phone:companies:notification", function(data)
local companies_locale = get_locale().Nui.Apps.companies
data.title = companies_locale.name
data.text = companies_locale.notifications[data.kind]
or companies_locale.notifications.requestUpdated
SendNUIMessage({ type = "companies:notification", data = data })
end)
RegisterNetEvent("sky_phone:fliptok:command-feedback", function(data)
Bridge.Framework.Notify("FlipTok", data.message, data.notificationType, 5000)
end)