Compare commits

..

1 Commits

Author SHA1 Message Date
Leon.Schmidt b65710bef0 PERF - bound crypto market history queries 2026-08-25 21:21:29 +02:00
21 changed files with 30 additions and 386 deletions
+2 -2
View File
@@ -89,7 +89,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| **Inventories** | ak47_inventory, codem-inventory, core_inventory, jaksam_inventory, jpr-inventory, lj-inventory, mf-inventory, one_inventory, origen_inventory, ox_inventory, ps-inventory, qb-inventory, qs-inventory, smx-inventory, tgiann-inventory, hex_4_inventory, and native ESX inventory |
| **Calls** | YACA, PMA Voice, SaltyChat |
| **Radio** | YACA, PMA Voice, SaltyChat |
| **Housing** | RTX Housing, Quasar Housing, TGIANN House, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
| **Languages** | English, German |
@@ -650,7 +650,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
### Housing
Select `rtx`, `quasar`, `tgiann`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API. The TGIANN bridge expects `tgiann-core` to start before `tgiann-house`, lists server-authorized owner properties, and supports entrance waypoints; TGIANN does not publish stable external contracts for keyholders, lock controls, CCTV, or live garage status.
Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API.
### Companies
+10 -8
View File
@@ -86,7 +86,6 @@ import { parsePhonePreferences } from '@/utils/preferences'
import { getHairlinePixelStyle } from '@/utils/rendering'
import { isTextInputElement } from '@/utils/textInputFocus'
import { configurePhoneNumberFormat } from '@/utils/phone'
import { consumeEscape } from '@/utils/keyboard'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
@@ -1316,17 +1315,20 @@ async function closePhone(): Promise<void> {
function onKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape') return
if (simPicker.value) {
if (!consumeEscape(event)) return
event.preventDefault()
void closeSimPicker()
return
}
if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return
if (controlCenterOpened.value) {
controlCenterOpened.value = false
return
}
void closePhone()
queueMicrotask(() => {
if (event.defaultPrevented || !phone.isOpen || activitySuspended.value)
return
if (controlCenterOpened.value) {
controlCenterOpened.value = false
return
}
void closePhone()
})
}
function onSystemColorSchemeChange(event: MediaQueryListEvent): void {
@@ -70,31 +70,6 @@ describe('browser development preview contract', () => {
expect(source).toContain(':device-pixel-ratio="browserDevicePixelRatio"')
})
it('clips composited app and overlay layers to the curved display', () => {
expect(mainCss).toMatch(
/\.phone-screen\s*\{[^}]*--phone-screen-radius:\s*40px;[^}]*overflow:\s*hidden;[^}]*border-radius:\s*var\(--phone-screen-radius\);[^}]*clip-path:\s*inset\(0 round var\(--phone-screen-radius\)\);/s,
)
})
it('replaces the CEF button focus rectangle around the home indicator', () => {
expect(mainCss).toMatch(
/\.phone-home-indicator:focus\s*\{[^}]*outline:\s*none;/s,
)
expect(mainCss).toMatch(
/\.phone-home-indicator:focus-visible span\s*\{[^}]*0 0 0 2px #0a84ff,/s,
)
})
it('consumes Escape synchronously before FiveM can open the pause menu', () => {
expect(source).toContain("import { consumeEscape } from '@/utils/keyboard'")
expect(source).toContain(
'if (!phone.isOpen || activitySuspended.value || !consumeEscape(event)) return',
)
expect(source).not.toMatch(
/function onKeydown\(event: KeyboardEvent\): void \{[\s\S]*?queueMicrotask/,
)
})
it('maps the visible device side controls to phone actions', () => {
expect(source).toContain('@click="toggleHardwareAlertMute"')
expect(source).toContain('@click="changeHardwareAlertVolume(10)"')
+1 -11
View File
@@ -460,7 +460,6 @@ button {
}
}
.phone-screen {
--phone-screen-radius: 40px;
--phone-screen-portrait-ratio: 2.30951;
position: relative;
container-type: size;
@@ -470,8 +469,7 @@ button {
height: 98%;
overflow: hidden;
background: #08080a;
border-radius: var(--phone-screen-radius);
clip-path: inset(0 round var(--phone-screen-radius));
border-radius: 40px;
}
.phone-screen--camera-landscape {
background: transparent;
@@ -858,14 +856,6 @@ button {
border-radius: 10px;
box-shadow: 0 1px 4px #0008;
}
.phone-home-indicator:focus {
outline: none;
}
.phone-home-indicator:focus-visible span {
box-shadow:
0 0 0 2px #0a84ff,
0 1px 4px #0008;
}
.phone-home-indicator--interactive {
cursor: pointer;
}
@@ -24,13 +24,4 @@ describe('EasyShareSheet Sky UI contract', () => {
/\.easyshare-history\s*\{[^}]*overflow:\s*hidden[^}]*background:\s*var\(--easyshare-list-surface\)/s,
)
})
it('only exposes and opens installed share destinations', () => {
expect(source).toContain("if (appStore.isInstalled('flare'))")
expect(source).toContain("if (appStore.isInstalled('darkchat'))")
expect(source).toContain('.filter((id) => appStore.isInstalled(id))')
expect(source).toContain('if (!appStore.isInstalled(kind)) return')
expect(source).toContain('if (!appStore.isInstalled(appId)) return')
expect(source).not.toContain('appStore.homeLayout.hidden.includes')
})
})
+3 -6
View File
@@ -67,7 +67,7 @@ const sharePeople = computed(() => {
}> = []
const phoneNumbers = new Set<string>()
if (appStore.isInstalled('flare')) {
if (!appStore.homeLayout.hidden.includes('flare')) {
for (const match of flare.matches) {
people.push({
avatar: match.profile.photoUrls[0],
@@ -102,7 +102,7 @@ const sharePeople = computed(() => {
phoneNumbers.add(contact.phone_number)
}
if (appStore.isInstalled('darkchat')) {
if (!appStore.homeLayout.hidden.includes('darkchat')) {
for (const conversation of darkChat.conversations.slice(0, 8)) {
people.push({
kind: 'darkchat',
@@ -116,7 +116,7 @@ const sharePeople = computed(() => {
})
const shareApps = computed(() =>
(easyShare.payload ? easyShareDestinationAppIds(easyShare.payload) : [])
.filter((id) => appStore.isInstalled(id))
.filter((id) => !appStore.homeLayout.hidden.includes(id))
.flatMap((id) => {
const app = getPhoneApp(id)
return app ? [{ app, id }] : []
@@ -195,21 +195,18 @@ function endDrag(event: PointerEvent): void {
}
function shareToChat(kind: EasyShareChatApp, targetId: string): void {
if (!appStore.isInstalled(kind)) return
if (!easyShare.prepareChatDraft(kind, targetId)) return
close()
void router.push(`/apps/${kind}`)
}
function openChatApp(kind: EasyShareChatApp): void {
if (!appStore.isInstalled(kind)) return
if (!easyShare.prepareChatDraft(kind)) return
close()
void router.push(`/apps/${kind}`)
}
function openShareApp(appId: EasyShareDestinationApp): void {
if (!appStore.isInstalled(appId)) return
if (appId === 'messages' || appId === 'darkchat' || appId === 'flare') {
openChatApp(appId)
return
@@ -1,63 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const readResourceFile = (path: string) =>
readFileSync(
new URL(`../../sky_phone/${path}`, import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
describe('housing provider contracts', () => {
it('registers TGIANN House with server-authorized owner properties', () => {
const adapter = readResourceFile('source/bridge/server/housing/tgiann.lua')
expect(adapter).toContain('local resource_name = "tgiann-house"')
expect(adapter).toContain('FROM `tgiann_house`')
expect(adapter).toContain('WHERE `owner` = ?')
expect(adapter).toContain('Bridge.Framework.GetIdentifier(source)')
expect(adapter).toContain(
'Bridge.Housing.RegisterProvider(provider_name, {',
)
expect(adapter).toContain('return nil, "property_access_denied"')
expect(adapter).not.toContain('`houseKeys`')
expect(adapter).not.toContain('tgiann-house:getPlayerHouses')
})
it('uses the published client export for entrance waypoints only', () => {
const adapter = readResourceFile('source/bridge/client/housing/tgiann.lua')
expect(adapter).toContain(
'return exports[resource_name]:getHouseData(house)',
)
expect(adapter).toContain(
'Bridge.Normalize.Coordinates(house_data.doorCoord)',
)
expect(adapter).toContain(
'Bridge.Housing.RegisterClientProvider(provider_name, {',
)
expect(adapter).toContain('SetNewWaypoint(')
expect(adapter).not.toContain('enterHouse(')
expect(adapter).not.toContain('forceOpenDoorHouse')
})
it('advertises TGIANN in housing configuration and documentation', () => {
const config = readResourceFile('config/config.lua')
const readme = readFileSync(
new URL('../../README.md', import.meta.url),
'utf8',
)
expect(config).toContain(
'"rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn"',
)
expect(readme).toContain('Quasar Housing, TGIANN House, VMS Housing')
expect(readme).toContain('`quasar`, `tgiann`, `vms`')
expect(readme).toContain(
'expects `tgiann-core` to start before `tgiann-house`',
)
expect(readme).toContain(
'lists server-authorized owner properties, and supports entrance waypoints',
)
})
})
-17
View File
@@ -264,23 +264,6 @@ describe('app store', () => {
expect(apps.homeLayout.hidden).not.toContain('snake')
})
it('uninstalls claimed Banking and Picstagram apps from every app state', () => {
const apps = useAppStoreStore()
apps.hydrate({ claimedApps: ['banking', 'picstagram'] })
mocks.phone.saveDeviceNamespace.mockClear()
for (const appId of ['banking', 'picstagram'] as const) {
expect(apps.isInstalled(appId)).toBe(true)
expect(apps.uninstallApp(appId)).toBe(true)
expect(apps.isInstalled(appId)).toBe(false)
expect(apps.claimedApps).not.toContain(appId)
expect(apps.uninstalledApps).toContain(appId)
expect(apps.homeLayout.hidden).toContain(appId)
}
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(2)
})
it('hydrates persisted removals while rejecting protected and invalid ids', () => {
const apps = useAppStoreStore()
-1
View File
@@ -3027,7 +3027,6 @@ const defaultLocales: LocaleTree = {
uninstallTitle: 'Uninstall this app?',
uninstallBody:
'{app} will be removed from this phone. You can download it again from the App Store.',
uninstallFailed: 'The app could not be uninstalled. Please try again.',
},
details: {
skyStudios: 'Sky Studios',
@@ -101,10 +101,6 @@ describe('AppStoreApp Sky navigation contract', () => {
expect(source).toContain(
'appStore.uninstallApp(uninstallCandidate.value.id)',
)
expect(source).toContain('@click.stop="requestUninstall(app)"')
expect(source).toContain('if (!appStore.uninstallApp(')
expect(source).toContain('Apps.appStore.account.uninstallFailed')
expect(source).toContain('role="alert"')
expect(source).toContain('v-if="isPhoneAppRemovable(app)"')
expect(source).toContain(':opened="Boolean(uninstallCandidate)"')
expect(source).toContain('class="store-account__grabber"')
+6 -32
View File
@@ -63,7 +63,6 @@ const openedAt = new Date()
const featuredSlide = ref(0)
const profileOpened = ref(false)
const uninstallCandidate = ref<LaunchablePhoneAppDefinition | null>(null)
const uninstallError = ref('')
const selectedApp = ref<LaunchablePhoneAppDefinition | null>(null)
const storeScroll = ref<ComponentPublicInstance | null>(null)
const featuredScroller = ref<HTMLElement | null>(null)
@@ -289,16 +288,6 @@ function closeProfile(): void {
profileDragOffset.value = 0
}
function requestUninstall(app: LaunchablePhoneAppDefinition): void {
uninstallError.value = ''
uninstallCandidate.value = app
}
function closeUninstallDialog(): void {
uninstallError.value = ''
uninstallCandidate.value = null
}
function beginProfileDrag(event: PointerEvent): void {
if (!profileOpened.value || event.button !== 0) return
profileDragPointerId = event.pointerId
@@ -329,11 +318,8 @@ function endProfileDrag(event: PointerEvent): void {
function confirmUninstall(): void {
if (!uninstallCandidate.value) return
if (!appStore.uninstallApp(uninstallCandidate.value.id)) {
uninstallError.value = phone.t('Apps.appStore.account.uninstallFailed')
return
}
closeUninstallDialog()
appStore.uninstallApp(uninstallCandidate.value.id)
uninstallCandidate.value = null
}
function highlightStyle(index: number): Record<string, string> {
@@ -1121,7 +1107,7 @@ watch(
app: getPhoneAppLabel(app, phone.t),
})
"
@click.stop="requestUninstall(app)"
@click="uninstallCandidate = app"
>
<Trash2 :size="17" :stroke-width="2" aria-hidden="true" />
</button>
@@ -1133,8 +1119,8 @@ watch(
<SkyDialog
:opened="Boolean(uninstallCandidate)"
role="alertdialog"
@backdropclick="closeUninstallDialog"
@escape="closeUninstallDialog"
@backdropclick="uninstallCandidate = null"
@escape="uninstallCandidate = null"
>
<template #title>
{{ phone.t('Apps.appStore.account.uninstallTitle') }}
@@ -1146,15 +1132,8 @@ watch(
})
}}
</p>
<p
v-if="uninstallError"
class="store-account__uninstall-error"
role="alert"
>
{{ uninstallError }}
</p>
<template #buttons>
<SkyDialogButton @click="closeUninstallDialog">
<SkyDialogButton @click="uninstallCandidate = null">
{{ phone.t('Common.cancel') }}
</SkyDialogButton>
<SkyDialogButton strong @click="confirmUninstall">
@@ -1497,11 +1476,6 @@ watch(
background: var(--sky-danger-soft);
}
.store-account__uninstall-error {
color: var(--sky-danger);
font-size: 12px;
}
.store-scroll {
min-height: 0;
flex: 1 1 auto;
+2 -2
View File
@@ -450,8 +450,8 @@ Config.Garage = {
}
Config.Housing = {
System = "auto", -- auto, rtx, quasar, tgiann, vms, rx, nolag, sn, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn" },
System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
MaximumProperties = 50,
OverviewRequestsPerMinute = 30,
ActionsPerMinute = 12,
+1 -2
View File
@@ -2022,11 +2022,10 @@ Locales["de"] = {
},
account = {
account = "Account", title = "App-Verwaltung", skyAccount = "Sky Phone Konto",
apps = "Installierte Apps", games = "Spiele", library = "Deine Bibliothek.", myApps = "Meine Apps",
apps = "Installation von Apps", games = "Spiele", library = "Deine Bibliothek.", myApps = "Meine Apps",
downloadedOn = "Gespeichert {date}", uninstall = "Deinstallieren", uninstallApp = "Deinstallieren {app}",
uninstallTitle = "Diese App deinstallieren?",
uninstallBody = "{app} wird von diesem Handy entfernt. Du kannst die App erneut aus dem App Store laden.",
uninstallFailed = "Die App konnte nicht deinstalliert werden. Bitte versuche es erneut.",
},
details = {
skyStudios = "Sky Studios", share = "App teilen", openDetails = "Ansicht {app}",
-1
View File
@@ -2026,7 +2026,6 @@ Locales["en"] = {
downloadedOn = "Downloaded {date}", uninstall = "Uninstall", uninstallApp = "Uninstall {app}",
uninstallTitle = "Uninstall this app?",
uninstallBody = "{app} will be removed from this phone. You can download it again from the App Store.",
uninstallFailed = "The app could not be uninstalled. Please try again.",
},
details = {
skyStudios = "Sky Studios", share = "Share app", openDetails = "View {app}",
-1
View File
@@ -2026,7 +2026,6 @@ Locales["es"] = {
downloadedOn = "Descargado {date}", uninstall = "Desinstalar", uninstallApp = "Desinstalar {app}",
uninstallTitle = "¿Desinstalar esta aplicación?",
uninstallBody = "{app} será eliminado de este teléfono. Puedes descargarlo de nuevo de la App Store.",
uninstallFailed = "No se ha podido desinstalar la aplicación. Inténtalo de nuevo.",
},
details = {
skyStudios = "Sky Studios", share = "Compartir aplicación", openDetails = "Ver {app}",
+1 -1
View File
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
author 'Sky-Systems'
description 'Sky Phone'
version '0.3.2'
version '0.3.1'
provide 'lb-phone'
provide '17mov_Phone'
@@ -1,73 +0,0 @@
local provider_name = "tgiann"
local resource_name = "tgiann-house"
local function house_reference(value)
if type(value) ~= "string" then
return nil
end
return value ~= "" and value or nil
end
local function house_entrance(house)
local success, house_data = pcall(function()
return exports[resource_name]:getHouseData(house)
end)
if not success then
Bridge.Debug(
"error",
"[sky_phone] tgiann-house:getHouseData failed for '%s': %s",
house,
tostring(house_data)
)
return nil, "provider_error"
end
local entrance = type(house_data) == "table"
and Bridge.Normalize.Coordinates(house_data.doorCoord) or nil
if not entrance then
return nil, "invalid_coordinates"
end
return entrance
end
local function enrich_overview(properties)
local result = {}
if GetResourceState(resource_name) ~= "started" or type(properties) ~= "table" then
return result
end
for _, property in ipairs(properties) do
local house = type(property) == "table" and house_reference(property.providerId) or nil
if house and property.id == provider_name .. ":" .. house then
local entrance = house_entrance(house)
if entrance then
result[#result + 1] = {
id = property.id,
entrance = entrance,
}
end
end
end
return result
end
Bridge.Housing.RegisterClientProvider(provider_name, {
enrich_overview = enrich_overview,
execute = function(action, data)
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if action ~= "set_waypoint" then
return false, "capability_unavailable"
end
local house = type(data) == "table" and house_reference(data.providerId) or nil
if not house then
return false, "invalid_property"
end
local entrance, error_code = house_entrance(house)
if not entrance then
return false, error_code
end
SetNewWaypoint(entrance.x + 0.0, entrance.y + 0.0)
return true
end,
})
@@ -1,124 +0,0 @@
local provider_name = "tgiann"
local resource_name = "tgiann-house"
local function query_owned_houses(identifier)
local success, properties = pcall(function()
return MySQL.query.await([[
SELECT `name`
FROM `tgiann_house`
WHERE `owner` = ?
ORDER BY `name` ASC
]], { identifier })
end)
if not success or type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] tgiann-house overview query failed: %s", tostring(properties))
return nil
end
return properties
end
local function owns_house(identifier, house)
local success, property = pcall(function()
return MySQL.single.await([[
SELECT `name`
FROM `tgiann_house`
WHERE `name` = ? AND `owner` = ?
LIMIT 1
]], { house, identifier })
end)
if not success then
Bridge.Debug("error", "[sky_phone] tgiann-house ownership query failed: %s", tostring(property))
return nil, "provider_error"
end
return type(property) == "table"
end
local function house_from_property_id(value)
if type(value) ~= "string" then
return nil
end
local house = value:match("^tgiann:(.+)$")
return house and house ~= "" and house or nil
end
local function normalized_property(house)
return {
id = provider_name .. ":" .. house,
providerId = house,
name = house,
access = "owner",
locked = false,
capabilities = {
lock = false,
keys = false,
waypoint = true,
cctv = false,
garageStatus = false,
},
cctv = { enabled = false },
garage = nil,
keys = nil,
}
end
Bridge.Housing.RegisterProvider(provider_name, {
resource_name = resource_name,
is_available = function()
return GetResourceState(resource_name) == "started"
end,
get_overview = function(source)
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, "housing_unavailable"
end
local properties = query_owned_houses(identifier)
if not properties then
return nil, "provider_error"
end
local result = {}
local seen = {}
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 0))
for _, property in ipairs(properties) do
if #result >= maximum then
break
end
local house = type(property.name) == "string" and property.name or nil
if house and house ~= "" and not seen[house] then
seen[house] = true
result[#result + 1] = normalized_property(house)
end
end
return result
end,
prepare = function(source, action, data)
if action == "toggle_lock" or action == "grant_key" or action == "revoke_key"
or action == "key_candidates"
then
return nil, "capability_unavailable"
end
if action == "open_cctv" then
return nil, "cctv_unavailable"
end
if action ~= "set_waypoint" then
return nil, "invalid_action"
end
local house = house_from_property_id(data and data.propertyId)
if not house then
return nil, "invalid_property"
end
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, "housing_unavailable"
end
local owned, error_code = owns_house(identifier, house)
if owned == nil then
return nil, error_code
end
if not owned then
return nil, "property_access_denied"
end
return { providerId = house }
end,
})
+1 -1
View File
@@ -1,6 +1,6 @@
SkyPhoneFocus = {}
local blocked_phone_controls = { 24, 140, 141, 142, 199, 200, 257, 263, 264 }
local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
local focused_control_groups = { 0, 1, 2 }
local hold_to_look_enabled = false
+2 -2
View File
@@ -435,8 +435,8 @@ Config.Garage = {
}
Config.Housing = {
System = "auto", -- auto, rtx, quasar, tgiann, vms, rx, nolag, sn, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn" },
System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
MaximumProperties = 50,
OverviewRequestsPerMinute = 30,
ActionsPerMinute = 12,
+1 -1
View File
@@ -252,7 +252,7 @@ assert(firing_disabled, "focused phone cursor must block attacks while typing")
all_controls_disabled = {}
firing_disabled = false
SkyPhoneFocus.ApplyGameInputControls(true)
for _, control in ipairs({ 24, 140, 141, 142, 199, 200, 257, 263, 264 }) do
for _, control in ipairs({ 24, 140, 141, 142, 257, 263, 264 }) do
assert(disabled_controls[control], ("phone control %d must remain disabled"):format(control))
end
assert(not disabled_controls[19], "Alt must remain available while no phone text input is focused")