Files
sky_phone-sky-systems/sky_phone/source/server/phone_persistence.lua
T
Leon.Schmidt 30559048fd 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>
2026-08-20 18:19:16 +02:00

221 lines
8.0 KiB
Lua

Bridge.Database.AfterMigration("sky_phone", function()
local max_device_data_bytes = 100000
local allowed_device_namespaces = {
settings = true,
notifications = true,
wallpaper = true,
alarms = true,
appAuth = true,
apps = true,
games = true,
widgets = true,
citywarn = true,
}
local function affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function valid_notifications(payload)
if type(payload) ~= "table" or payload.version ~= 1 or type(payload.items) ~= "table" then
return false
end
local item_count = #payload.items
local key_count = 0
for key in pairs(payload.items) do
if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > item_count then
return false
end
key_count = key_count + 1
end
if key_count ~= item_count then
return false
end
for _, item in ipairs(payload.items) do
if type(item) ~= "table"
or type(item.appId) ~= "string" or #item.appId > 64
or type(item.id) ~= "string" or #item.id > 100
or type(item.title) ~= "string" or #item.title > 200
or type(item.text) ~= "string" or #item.text > 2000
or (item.subtitle ~= nil and (type(item.subtitle) ~= "string" or #item.subtitle > 200))
then
return false
end
end
return true
end
Bridge.Callbacks.Register("sky_phone:device:save", function(source, data)
if not SkyPhone.AllowOperation(source, "device_save", 120, 60) then
return { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
if data.imei ~= session.imei or data.sessionToken ~= session.token then
return { success = false, error = "stale_session" }
end
if not allowed_device_namespaces[data.namespace] then
return { success = false, error = "invalid_namespace" }
end
local encoded = json.encode(data.payload)
if #encoded > max_device_data_bytes then
return { success = false, error = "payload_too_large" }
end
local revision = math.max(0, math.floor(tonumber(data.revision) or 0))
local rows = Bridge.Database.Query([[
SELECT `payload`, `revision`
FROM `sky_phone_device_data`
WHERE `device_imei` = ? AND `namespace` = ?
LIMIT 1
]], { session.imei, data.namespace })
if rows[1] then
local current_revision = tonumber(rows[1].revision) or 0
if revision ~= current_revision then
SkyPhone.RefreshSource(source)
return {
success = false,
error = "conflict",
data = { payload = json.decode(rows[1].payload), revision = current_revision },
}
end
local result = Bridge.Database.Query([[
UPDATE `sky_phone_device_data`
SET `payload` = ?, `revision` = `revision` + 1
WHERE `device_imei` = ? AND `namespace` = ? AND `revision` = ?
]], { encoded, session.imei, data.namespace, revision })
if affected_rows(result) ~= 1 then
SkyPhone.RefreshSource(source)
return { success = false, error = "conflict" }
end
return { success = true, data = { revision = revision + 1 } }
end
if revision ~= 0 then
return { success = false, error = "conflict" }
end
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`)
VALUES (?, ?, ?)
]], { session.imei, data.namespace, encoded })
if affected_rows(result) <= 0 then
SkyPhone.RefreshSource(source)
return { success = false, error = "conflict" }
end
return { success = true, data = { revision = 1 } }
end)
Bridge.Callbacks.Register("sky_phone:notifications:save", function(source, data)
if not SkyPhone.AllowOperation(source, "notifications_save", 240, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) or type(data.payload) ~= "table" then
return { success = false, error = "invalid_request" }
end
if #SkyPhone.FindDeviceSlots(source, data.imei) == 0 then
Bridge.Debug(
"warn",
"[sky_phone] Source %s attempted to save notifications for unowned device %s.",
tostring(source),
tostring(data.imei)
)
return { success = false, error = "device_not_owned" }
end
if not valid_notifications(data.payload) then
return { success = false, error = "invalid_request" }
end
local encoded = json.encode(data.payload)
if #encoded > max_device_data_bytes then
return { success = false, error = "payload_too_large" }
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_device_data` (`device_imei`, `namespace`, `payload`)
VALUES (?, 'notifications', ?)
ON DUPLICATE KEY UPDATE `payload` = VALUES(`payload`), `revision` = `revision` + 1
]], { data.imei, encoded })
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
if not SkyPhone.AllowOperation(source, "factory_reset", 3, 60) then
return { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
local device = SkyPhone.LoadDevice(session.imei)
local phone_number = device and device.phone_number or nil
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(session.imei)
if not Bridge.Database.Transaction({
{
query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_custom_app_data` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_notes` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_music_playlists` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_music_youtube_songs` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { session.imei },
},
{
query = "DELETE FROM `sky_phone_fliptok_sessions` WHERE `device_imei` = ?",
params = { session.imei },
},
{
query = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
params = { Config.Phone.DeviceName, session.imei },
},
}) then
return { success = false, error = "request_failed" }
end
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
session.unlocked = true
if phone_number then
TriggerEvent("sky_phone:server:factoryReset", source, phone_number)
end
SkyPhone.RefreshSource(source)
return { success = true }
end)
end)