Files
Leon.Schmidt 36b859cabb ENH - add persistent dynamic island activities (#17)
* FIX - layer dynamic island above phone camera

The phone screen stacking context kept the Dynamic Island below the physical frame and its camera lens. Render the island at device-shell level, position it around the lens, and keep expanded notification spacing in sync.

* ADD - preview all dynamic island states

Reuse the runtime component for a development-only gallery and size expanded previews to the actual phone display so they cannot be clipped.

* FIX - match dynamic island live activity behavior

Show each activity only outside its owning app, use reference-sized activity layouts, and remove the separate expand glyph in favor of direct island taps.

* FIX - refine dynamic island live controls

Keep background activity state separate from foreground visibility so the island remains visible in the compact closed-phone peek. Match the timer, stopwatch, music, collapse, and spacing behavior to the supplied references.

* FIX - keep business call availability after closing

Stop treating the closed phone UI as the end of business-call readiness. Route background service-line calls only after revalidating the employee, owned device, and registered SIM.

* FIX - remove dock apps from home grid

The stored Home layout treated dock and grid shortcuts independently, so default dock apps appeared twice. Let the dock own those default placements and migrate saved layouts by removing matching grid shortcuts.

* FIX - keep memo island active after closing

Memo recording was cancelled as soon as the UI closed, while uploads required an active UI session and reopening a locked phone forced the home route. Keep the recorder bound to the physical device, revalidate device ownership server-side for uploads, preserve the last route behind the lock screen, and render only a compact frame, display, and live Dynamic Island while closed.

* ENH - align live activity controls with phone hardware

Report compact live-activity state to the client so Space opens Home or the enabled lock screen contextually. Apply the phone hardware volume as a shared output level for app media, games, YouTube playback, radio, and auxiliary sounds, and render the volume speaker icon in grey.

* FIX - hide paused music live activity

The music island previously depended only on the selected track, which remains set after playback pauses. Require active playback as well so the island closes when music stops while preserving the track for later resume.

---------

Co-authored-by: DerEchteAlec <bycraky@gmail.com>
2026-08-20 18:59:51 +02:00

550 lines
21 KiB
Lua

Bridge.Database.AfterMigration("sky_phone", function()
SkyPhoneMemos = {}
local pending_uploads = {}
local pending_deletes = {}
local allowed_audio_mimes = {
["audio/ogg"] = "audio/ogg",
["audio/webm"] = "audio/webm",
["audio/webm;codecs=opus"] = "audio/webm",
}
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 trim(value)
if type(value) ~= "string" then
return nil
end
return value:match("^%s*(.-)%s*$")
end
local function valid_uuid(value)
if type(value) ~= "string" or #value ~= 36
or value:sub(9, 9) ~= "-"
or value:sub(14, 14) ~= "-"
or value:sub(19, 19) ~= "-"
or value:sub(24, 24) ~= "-"
then
return false
end
local compact = value:gsub("-", "")
return #compact == 32 and compact:match("^%x+$") ~= nil
end
local function session_owner(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, error_response
end
local device = SkyPhone.LoadDevice(session.imei)
if not device then
return nil, { success = false, error = "device_not_found" }
end
return {
account_id = device.account_id and tonumber(device.account_id) or nil,
imei = session.imei,
}
end
local function device_owner(source, imei)
if not SkyPhoneImei.IsValid(imei) then
return nil, { success = false, error = "invalid_request" }
end
if not SkyPhone.FindDeviceSlots(source, imei)[1] then
return nil, { success = false, error = "owner_changed" }
end
local device = SkyPhone.LoadDevice(imei)
if not device then
return nil, { success = false, error = "device_not_found" }
end
return {
account_id = device.account_id and tonumber(device.account_id) or nil,
imei = imei,
}
end
local function owner_condition(owner, alias)
local prefix = alias and ("`%s`."):format(alias) or ""
if owner.account_id then
return prefix .. "`account_id` = ?", { owner.account_id }
end
return prefix .. "`account_id` IS NULL AND " .. prefix .. "`device_imei` = ?", { owner.imei }
end
local function owner_key(owner)
if owner.account_id then
return "account:" .. tostring(owner.account_id)
end
return "device:" .. owner.imei
end
local function append_params(target, values)
for _, value in ipairs(values) do
target[#target + 1] = value
end
end
local function memo_dto(row)
local waveform = json.decode(row.waveform)
if type(waveform) ~= "table" then
error(("[sky_phone] Voice memo %s has an invalid waveform payload."):format(tostring(row.id)))
end
return {
id = row.id,
title = row.title,
note = row.note,
url = row.url,
mimeType = row.mime_type,
durationMs = tonumber(row.duration_ms) or 0,
waveform = waveform,
pinned = tonumber(row.pinned) == 1,
revision = tonumber(row.revision) or 1,
createdAt = (tonumber(row.created_at_unix) or 0) * 1000,
updatedAt = (tonumber(row.updated_at_unix) or 0) * 1000,
}
end
local function list_memos(owner)
local condition, params = owner_condition(owner, "media")
params[#params + 1] = Config.Memos.MaximumCount
local rows = Bridge.Database.Query(([[
SELECT memo.`id`, memo.`title`, memo.`note`, memo.`duration_ms`,
memo.`waveform`, memo.`pinned`, memo.`revision`, media.`url`, media.`mime_type`,
UNIX_TIMESTAMP(memo.`created_at`) AS `created_at_unix`,
UNIX_TIMESTAMP(memo.`updated_at`) AS `updated_at_unix`
FROM `sky_phone_voice_memos` memo
INNER JOIN `sky_phone_media` media ON media.`id` = memo.`media_id`
WHERE %s AND media.`media_type` = 'audio'
ORDER BY memo.`pinned` DESC, memo.`updated_at` DESC, memo.`id` DESC
LIMIT ?
]]):format(condition), params)
local memos = {}
for index, row in ipairs(rows) do
memos[index] = memo_dto(row)
end
return memos
end
local function find_memo(memos, id)
for _, memo in ipairs(memos) do
if memo.id == id then
return memo
end
end
end
function SkyPhoneMemos.List(account_id, imei)
return list_memos({ account_id = account_id and tonumber(account_id) or nil, imei = imei })
end
local function normalize_waveform(value)
if type(value) ~= "table" or #value < 8 or #value > Config.Memos.MaximumWaveformSamples then
return nil
end
local waveform = {}
for index = 1, #value do
local sample = tonumber(value[index])
if not sample or sample ~= sample or sample == math.huge or sample == -math.huge
or sample < 0 or sample > 1
then
return nil
end
waveform[index] = math.floor(sample * 1000 + 0.5) / 1000
end
return waveform
end
local function validate_upload(data)
if type(data) ~= "table" or type(data.correlationId) ~= "string"
or #data.correlationId < 1 or #data.correlationId > 80
then
return nil
end
local title = trim(data.title)
local note = data.note == nil and "" or trim(data.note)
local title_length = title and utf8.len(title) or nil
local note_length = note and utf8.len(note) or nil
local duration_ms = tonumber(data.durationMs)
local normalized_mime = allowed_audio_mimes[data.mimeType]
local waveform = normalize_waveform(data.waveform)
if not title_length or title_length < 1 or title_length > Config.Memos.TitleMaxLength
or not note_length or note_length > Config.Memos.NoteMaxLength
or not duration_ms or duration_ms ~= duration_ms
or duration_ms == math.huge or duration_ms == -math.huge
or duration_ms < 300 or duration_ms > Config.Memos.MaximumDurationMs
or not normalized_mime or not waveform or type(data.pinned) ~= "boolean"
then
return nil
end
duration_ms = math.floor(duration_ms)
return {
correlation_id = data.correlationId,
duration_ms = duration_ms,
mime_type = normalized_mime,
note = note,
pinned = data.pinned,
title = title,
waveform = waveform,
}
end
local function upload_result(source, correlation_id, success, error_code, memo)
TriggerClientEvent("sky_phone:memos:upload-result", source, {
correlationId = correlation_id,
success = success,
error = error_code,
memo = memo,
})
end
local function discard_verified_upload(remote_id)
local deleted, delete_error = SkyPhoneMedia.DeleteRemoteFile(remote_id)
if not deleted then
Bridge.Debug(
"warn",
"[sky_phone] Could not remove rejected voice memo upload %s: %s.",
tostring(remote_id),
tostring(delete_error)
)
end
end
local function expire_upload(request_id)
local state = pending_uploads[request_id]
if not state or state.completing then
return
end
pending_uploads[request_id] = nil
upload_result(state.source, state.memo.correlation_id, false, "upload_timeout")
end
Bridge.Callbacks.Register("sky_phone:memos:list", function(source)
if not SkyPhone.AllowOperation(source, "memos_list", Config.Memos.ListsPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local owner, error_response = session_owner(source)
if not owner then
return error_response
end
return { success = true, data = list_memos(owner) }
end)
Bridge.Callbacks.Register("sky_phone:memos:update", function(source, data)
if not SkyPhone.AllowOperation(source, "memos_write", Config.Memos.WritesPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local owner, error_response = session_owner(source)
if not owner then
return error_response
end
local title = type(data) == "table" and trim(data.title) or nil
local note = type(data) == "table" and (data.note == nil and "" or trim(data.note)) or nil
local title_length = title and utf8.len(title) or nil
local note_length = note and utf8.len(note) or nil
local revision = type(data) == "table" and tonumber(data.revision) or nil
if type(data) ~= "table" or not valid_uuid(data.id)
or not title_length or title_length < 1 or title_length > Config.Memos.TitleMaxLength
or not note_length or note_length > Config.Memos.NoteMaxLength
or type(data.pinned) ~= "boolean" or not revision or revision ~= revision
or revision == math.huge or revision == -math.huge or revision ~= math.floor(revision)
or revision < 1 or revision > 4294967295
then
return { success = false, error = "invalid_memo" }
end
local condition, owner_params = owner_condition(owner, "media")
local params = { title, note, data.pinned and 1 or 0, data.id, revision }
append_params(params, owner_params)
local result = Bridge.Database.Query(([[
UPDATE `sky_phone_voice_memos` memo
INNER JOIN `sky_phone_media` media ON media.`id` = memo.`media_id`
SET memo.`title` = ?, memo.`note` = ?, memo.`pinned` = ?, memo.`revision` = memo.`revision` + 1
WHERE memo.`id` = ? AND memo.`revision` = ? AND %s AND media.`media_type` = 'audio'
]]):format(condition), params)
if affected_rows(result) ~= 1 then
return { success = false, error = "conflict", data = list_memos(owner) }
end
return { success = true, data = find_memo(list_memos(owner), data.id) }
end)
Bridge.Callbacks.Register("sky_phone:memos:delete", function(source, data)
if not SkyPhone.AllowOperation(source, "memos_write", Config.Memos.WritesPerMinute, 60) then
return { success = false, error = "rate_limited" }
end
local owner, error_response = session_owner(source)
if not owner then
return error_response
end
if type(data) ~= "table" or not valid_uuid(data.id) then
return { success = false, error = "invalid_memo" }
end
local condition, owner_params = owner_condition(owner, "media")
local params = { data.id }
append_params(params, owner_params)
local rows = Bridge.Database.Query(([[
SELECT media.`id` AS `media_id`, media.`remote_id`
FROM `sky_phone_voice_memos` memo
INNER JOIN `sky_phone_media` media ON media.`id` = memo.`media_id`
WHERE memo.`id` = ? AND %s AND media.`media_type` = 'audio'
LIMIT 1
]]):format(condition), params)
local memo = rows[1]
if not memo then
return { success = false, error = "memo_not_found" }
end
if pending_deletes[memo.remote_id] then
return { success = false, error = "operation_in_progress" }
end
pending_deletes[memo.remote_id] = source
local references = Bridge.Database.Query(
"SELECT COUNT(*) AS `count` FROM `sky_phone_media` WHERE `remote_id` = ?",
{ memo.remote_id }
)
if (tonumber(references[1] and references[1].count) or 0) <= 1 then
local deleted, delete_error = SkyPhoneMedia.DeleteRemoteFile(memo.remote_id)
if not deleted then
pending_deletes[memo.remote_id] = nil
return { success = false, error = delete_error }
end
end
local result = Bridge.Database.Query(([[
DELETE media FROM `sky_phone_media` media
INNER JOIN `sky_phone_voice_memos` memo ON memo.`media_id` = media.`id`
WHERE memo.`id` = ? AND %s AND media.`media_type` = 'audio'
]]):format(condition), params)
pending_deletes[memo.remote_id] = nil
if affected_rows(result) ~= 1 then
error(("[sky_phone] Voice memo %s disappeared during deletion."):format(data.id))
end
return { success = true, data = { id = data.id } }
end)
RegisterNetEvent("sky_phone:memos:request-upload", function(data)
local src = source
if not SkyPhone.AllowOperation(src, "memos_upload", Config.Memos.UploadsPerMinute, 60) then
upload_result(src, type(data) == "table" and data.correlationId or nil, false, "rate_limited")
return
end
local memo = validate_upload(data)
if not memo then
upload_result(src, type(data) == "table" and data.correlationId or nil, false, "invalid_memo")
return
end
local owner, error_response = device_owner(src, data.deviceImei)
if not owner then
upload_result(src, memo.correlation_id, false, error_response.error)
return
end
local condition, params = owner_condition(owner, "media")
local counts = Bridge.Database.Query(([[
SELECT COUNT(*) AS `count`
FROM `sky_phone_voice_memos` memo
INNER JOIN `sky_phone_media` media ON media.`id` = memo.`media_id`
WHERE %s AND media.`media_type` = 'audio'
]]):format(condition), params)
if (tonumber(counts[1] and counts[1].count) or 0) >= Config.Memos.MaximumCount then
upload_result(src, memo.correlation_id, false, "memo_limit")
return
end
local pending_count = 0
local pending_owner_key = owner_key(owner)
for _, state in pairs(pending_uploads) do
if state.owner_key == pending_owner_key then
pending_count = pending_count + 1
end
end
if pending_count >= Config.Memos.MaximumPendingUploads then
upload_result(src, memo.correlation_id, false, "operation_in_progress")
return
end
local presigned_url, presigned_error = SkyPhoneMedia.RequestPresignedUrl()
if not presigned_url then
Bridge.Debug(
"error",
"[sky_phone] Voice memo upload could not start for source %s: %s.",
tostring(src),
tostring(presigned_error),
{ always = true }
)
upload_result(src, memo.correlation_id, false, presigned_error)
return
end
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {})
local request_id = ids[1] and ids[1].request_id
local capture_token = ids[1] and ids[1].capture_token
if type(request_id) ~= "string" or type(capture_token) ~= "string" then
error("[sky_phone] Database did not generate a voice memo upload token.")
end
pending_uploads[request_id] = {
capture_token = capture_token,
media_type = "audio",
mime_type = memo.mime_type,
memo = memo,
owner = owner,
owner_key = pending_owner_key,
purpose = "memo",
source = src,
}
SetTimeout(Config.Memos.UploadSessionTimeoutMs, function()
expire_upload(request_id)
end)
TriggerClientEvent("sky_phone:memos:upload-ready", src, {
requestId = request_id,
correlationId = memo.correlation_id,
captureToken = capture_token,
presignedUrl = presigned_url,
uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs,
})
end)
RegisterNetEvent("sky_phone:memos:complete-upload", function(data)
local src = source
local request_id = type(data) == "table" and data.requestId or nil
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if not state or state.source ~= src or state.completing then
return
end
state.completing = true
local owner, error_response = device_owner(src, state.owner.imei)
if not owner or owner.imei ~= state.owner.imei or owner.account_id ~= state.owner.account_id then
pending_uploads[request_id] = nil
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
if rejected or trusted_remote then
discard_verified_upload(data.remoteId)
end
upload_result(src, state.memo.correlation_id, false, error_response and error_response.error or "owner_changed")
return
end
local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
if not verified then
pending_uploads[request_id] = nil
if trusted_remote then
discard_verified_upload(data.remoteId)
end
upload_result(src, state.memo.correlation_id, false, verify_error)
return
end
local size_bytes = tonumber(verified.size)
if not size_bytes or size_bytes ~= size_bytes
or size_bytes == math.huge or size_bytes == -math.huge
or size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes
then
pending_uploads[request_id] = nil
discard_verified_upload(verified.remote_id)
upload_result(src, state.memo.correlation_id, false, "recording_too_large")
return
end
size_bytes = math.floor(size_bytes)
local condition, count_params = owner_condition(owner, "media")
local counts = Bridge.Database.Query(([[
SELECT COUNT(*) AS `count`
FROM `sky_phone_voice_memos` memo
INNER JOIN `sky_phone_media` media ON media.`id` = memo.`media_id`
WHERE %s AND media.`media_type` = 'audio'
]]):format(condition), count_params)
if (tonumber(counts[1] and counts[1].count) or 0) >= Config.Memos.MaximumCount then
pending_uploads[request_id] = nil
discard_verified_upload(verified.remote_id)
upload_result(src, state.memo.correlation_id, false, "memo_limit")
return
end
local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local memo_id = ids[1] and ids[1].id
if type(memo_id) ~= "string" then
error("[sky_phone] Database did not generate a voice memo id.")
end
local media_query
local media_params
if owner.account_id then
media_query = [[
INSERT INTO `sky_phone_media`
(`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`, `verified_at`)
VALUES (?, NULL, ?, ?, 'audio', ?, CURRENT_TIMESTAMP)
]]
media_params = { owner.account_id, verified.url, verified.remote_id, verified.mime_type }
else
media_query = [[
INSERT INTO `sky_phone_media`
(`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`, `verified_at`)
VALUES (NULL, ?, ?, ?, 'audio', ?, CURRENT_TIMESTAMP)
]]
media_params = { owner.imei, verified.url, verified.remote_id, verified.mime_type }
end
local transaction = {
{
query = media_query,
params = media_params,
},
{
query = [[
INSERT INTO `sky_phone_voice_memos`
(`id`, `media_id`, `title`, `note`, `duration_ms`, `size_bytes`, `waveform`, `pinned`)
SELECT ?, LAST_INSERT_ID(), ?, ?, ?, ?, ?, ?
]],
params = {
memo_id,
state.memo.title,
state.memo.note,
state.memo.duration_ms,
size_bytes,
json.encode(state.memo.waveform),
state.memo.pinned and 1 or 0,
},
},
}
if not Bridge.Database.Transaction(transaction) then
pending_uploads[request_id] = nil
discard_verified_upload(verified.remote_id)
upload_result(src, state.memo.correlation_id, false, "request_failed")
return
end
pending_uploads[request_id] = nil
local memos = list_memos(owner)
local created = find_memo(memos, memo_id)
if not created then
error(("[sky_phone] Created voice memo %s could not be hydrated."):format(memo_id))
end
upload_result(src, state.memo.correlation_id, true, nil, created)
end)
RegisterNetEvent("sky_phone:memos:cancel-upload", function(data)
local src = source
local request_id = type(data) == "table" and data.requestId or nil
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if state and state.source == src and not state.completing then
pending_uploads[request_id] = nil
upload_result(src, state.memo.correlation_id, false, "cancelled")
end
end)
RegisterNetEvent("sky_phone:memos:fail-upload", function(data)
local src = source
local request_id = type(data) == "table" and data.requestId or nil
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if not state or state.source ~= src or state.completing then
return
end
local allowed_errors = {
capture_failed = true,
unsupported = true,
upload_failed = true,
upload_timeout = true,
}
pending_uploads[request_id] = nil
upload_result(src, state.memo.correlation_id, false, allowed_errors[data.error] and data.error or "upload_failed")
end)
AddEventHandler("playerDropped", function()
local src = source
for request_id, state in pairs(pending_uploads) do
if state.source == src then
pending_uploads[request_id] = nil
end
end
end)
end)