Building Apps for the Phone

Developer guide for putting your own resource on oxide-phone — registering apps, the web SDK, widgets, notifications and badges, security, and phone services such as permissions, media, links, accounts, evidence, live streams and custom photo storage.

The phone can show your resource's own UI as an app, put a widget from your resource on the home screen, and post notifications for it. Your resource talks to the phone only through o-link's phone namespace, so it needs no dependency on the phone and keeps working on servers that run a different phone or none.

Apps built this way also run on oxide-tablet, which speaks the same app protocol. Register with each host separately (olink.phone.* and olink.tablet.*).

1. How the phone hosts your resource

Apps. An app is a page from your resource's own NUI bundle. The phone shows it full screen in an iframe at

https://cfx-nui-<your-resource>/<url>?appHost=oxide-phone&appForm=phone&appId=<id>&appDevice=<device id>

plus tabletHost=oxide-phone&tabletApp=<id>, so pages written for oxide-tablet's older shim also detect that they are hosted. Your page keeps its own origin, its own NUI callbacks and its own Lua; the phone only frames it.

Widgets. A widget is a tile on the home screen. Most widgets are drawn by the phone from data you push (a number, a list, a progress bar, a short text). A frame widget is a small page from your bundle, like an app.

Registration is per player. Apps and widgets are registered on each client and forgotten when the phone restarts. Register when your resource starts and again on olink:client:phone:ready, which the phone fires about a second after it starts.

Players install apps. Registering an app does not put it on anyone's phone. Players get it from the Store on each phone they own. The server owner can list apps as preinstalled (always on every phone, cannot be removed) or turn the Store off (then every app the player may use is on every phone).

Getting an app takes a few seconds. Unless the server owner turned simulated installs off, Get starts a download the server runs: the icon sits dimmed on the home screen with a progress pie until it finishes, and the time depends on your app's size. Until then your app is not on that phone: it cannot be opened (Open from your Lua does nothing for that player either), its notifications and badges are dropped, and its widgets stay hidden. Nothing for you to handle; register as usual.

requires limits who sees an app. requires = { jobs = { 'police' }, duty = true } shows the app only to on-duty police; gangs = { 'ballas' } to a gang. Either list grants. A player who stops qualifying loses the app from the home screen, the Store, its widgets and its notifications; an app on screen is closed. Opening the app from your own Lua with Open works whether or not the player installed it, but never for a player requires leaves out, and not while it is still downloading on that phone.

2. Register an app

-- client.lua
olink = exports['o-link']:olink()

local registered = false

local function registerPhoneApp()
    local register = olink.phone and olink.phone.RegisterApp
    if not register then return end
    local ok, result = pcall(register, {
        id = 'fleeca_bank',
        label = 'Fleeca',
        icon = 'fa-solid fa-building-columns',
        color = '#3F8F7A',
        resource = GetCurrentResourceName(),
        url = 'web/dist/index.html',
        category = 'finance',
        tagline = 'Accounts, transfers and invoices',
    })
    registered = ok and result == true
end

CreateThread(registerPhoneApp)
AddEventHandler('olink:client:phone:ready', registerPhoneApp)

Functions reached through exports['o-link']:olink() cross the resource boundary as callable tables: nil-check the member and call it through pcall. RegisterApp returns true only when this phone is the one o-link uses; every other phone returns false. Keep your own UI for that case.

FieldRequiredMeaning
idyes1 to 64 characters of A-Z a-z 0-9 _ -. The first resource to register an id keeps it
labelyesName under the icon
resourceyesYour resource name. The page is served from it and requests are checked against it
urlnoPage inside your resource. Default web/dist/index.html
iconnoFont Awesome 6 class, e.g. fa-solid fa-car. Default fa-solid fa-cube
colorno6-digit hex for the icon glyph
querynoExtra URL parameters. The phone's own parameters (appHost, appId, appForm, appDevice, appWidget, appSize, tabletHost, tabletApp, tabletWidget, tabletSize, tabletImei) are dropped from it
requiresno{ jobs?, gangs?, duty? }, see above
ordernoSort order in the Store and the default home screen. Default 100
readyTimeoutMsnoHow long the page may take to report ready. Default from the phone's settings (8 s)
categorynobusiness, finance, law, medical, social, utilities (default) or other
descriptionnoStore text, up to 280 bytes. Default: your manifest's description
taglinenoOne line under the name, up to 80 bytes
publishernoUp to 60 bytes. Default: your manifest's author
versionnoUp to 24 bytes. Default: your manifest's version
linksnoLink patterns that open your app, e.g. { 'snapgram://post/:id' }. See Links
permissionsnoWhat your app may ask for and the sentence its prompt shows: { photos = 'Post photos from your library.', camera = '...', microphone = '...', location = '...' }. See Permissions
iconImagenoYour own icon: an SVG, PNG or WebP file in your resource, e.g. web/dist/icon.svg. See below
iconBackgroundno1 to 3 hex colours behind iconImage, drawn as a diagonal gradient: '#9B2C2C' or { '#F58529', '#DD2A7B', '#8134AF' }
accountsnotrue when your app uses the phone's accounts. Its icon badge is then kept per phone: set it while the phone logged in to the account is out (see olink:client:phone:opened below)
sizenoYour app's download size in MB, 1 to 4000. The Store shows it, and a bigger app downloads longer (about 6 MB a second on full signal at the owner's default speed). Default: the owner's default size, 50 MB

Your own icon. With iconImage, the phone draws your picture wherever it shows your app: the home screen and dock, folders, All Apps, Search, the Store, Settings, notifications, the app switcher and the loading screen, at every size.

iconImage = 'web/dist/icon.svg',
iconBackground = { '#F58529', '#DD2A7B', '#8134AF' },
  • Ship the file like your page: in your resource, and listed in your fxmanifest.lua files { }. With Vite, put it in web/public/ and the build copies it to web/dist/.
  • The path is inside your resource: up to 128 characters of letters, digits, _, -, . and /, ending in .svg, .png or .webp. Web addresses and .. are refused, and the phone then shows icon instead.
  • Draw it square. The phone fills the tile with it and clips it to the icon shape. Leave the picture's background transparent to show iconBackground (or color when you give no background) behind it.
  • Keep icon and color: the phone uses them when the picture does not load, and so do hosts that cannot show pictures.
  • With the player's Dark or Tinted icon style, the phone draws its own tile behind your picture, and a tinted icon shows your picture in grey.

UnregisterApp(id) removes it. When your resource stops, the phone removes its apps and widgets by itself.

3. Open, send and close

Client

FunctionReturnsMeaning
Open(appId?)booleanOpen the phone the player carries, straight into appId if given. With the phone already open, switch to appId
Close()booleanPut the phone away
CloseApp(appId?)booleanBack to the home screen if appId (or any app) is on screen
GetCurrentApp()string|nilThe app on screen
IsOpen()booleanThe phone is on screen
Send(appId, message)booleanPost message ({ action = 'name', data = ... }) into your page. false unless that app is on screen
SetBadge(appId, count)booleanThe number on the icon

Server

FunctionReturnsMeaning
Open(src, appId?)booleanOpen the player's phone, optionally into an app
Close(src)booleanPut the player's phone away
Send(src, appId, message)booleanAs on the client, for that player
SetWidgetData(src, id, data)booleanSee Widgets
Notify(src, def), DismissNotification(src, id), ClearNotifications(src, appId?)See Notifications

A message sent while your page is still loading is held until it reports ready, so you can Open and Send back to back.

4. Events

Client events, fired with TriggerEvent:

EventArgumentsWhen
olink:client:phone:readyThe phone started; register again
olink:client:phone:appOpenedappIdAn app came on screen
olink:client:phone:appReadyappIdThe page on screen reported ready
olink:client:phone:appClosedappId, reasonreason is closed, home, switch or unregistered, or the reason the phone was put away while your app was on screen
olink:client:phone:widgetReadywidgetIdA frame widget reported ready
olink:client:phone:notificationTappedid, appId, dataThe player tapped one of your notifications
olink:client:phone:openeddeviceIdA phone came out. An app with accounts asks its server for the badge of the account on that phone (server service account.open)
olink:client:phone:closedThe phone was put away
oxide:phone:opened / oxide:phone:closedThe phone came on screen / went away (for HUDs that hide themselves)

5. The web side

5.1 Copy the SDK

Copy sdk/host.js (and sdk/host.d.ts for TypeScript) from the phone's folder into your web source, for example web/src/utils/host.js, and import from it. Do not load it from the phone's folder at runtime: your page must work without the phone.

For a working reference, turn on Debug mode (/phone settings → Advanced). The Store then offers a Demo app: web/dist/demo.html in the phone's folder, which uses every part of the SDK described below.

5.2 Detect hosting and report ready

import { isHosted, formFactor, markReady, forwardEscapeToHost, forwardMovementToHost, watchInputFocus } from './utils/host.js'

if (isHosted) {
  document.documentElement.classList.add(`hosted-${formFactor}`)   // 'phone' or 'tablet'
  forwardEscapeToHost()
  forwardMovementToHost()
  watchInputFocus()
}

// ...mount your app and register your message listeners, then:
markReady()
  • markReady() tells the phone your page can take messages. Until then the phone shows a loading screen and holds Send messages. A page that never reports ready gets an error screen with a Retry button after readyTimeoutMs.
  • Keys. While the phone is open the game gets no key presses; the phone walks the player from the movement keys. Key presses inside your iframe never reach the phone, so forward them: forwardEscapeToHost() hands ESC to the phone (back to the home screen), forwardMovementToHost() keeps walking working while your page has focus, and watchInputFocus() stops walking while the player types in one of your fields.
  • Full screen, with a safe area. Your page fills the phone's screen. The status bar covers the top 44 px and the home bar the bottom 28 px; keep buttons out of those strips. The numbers also arrive in the context (below). vh units refer to the iframe, not the game window. As with frame widgets, leave color-scheme unset on your root element (see Frame widgets).

5.3 Receive messages

Send arrives as a window message with the SendNUIMessage shape:

window.addEventListener('message', (e) => {
  if (e.source !== window.parent) return
  const { action, data } = e.data || {}
  if (action === 'balance') showBalance(data)
})

5.4 Context

import { onHostContext } from './utils/host.js'

onHostContext(({ host, form, theme, textScale, locale, safeArea, accent }) => {
  document.documentElement.style.setProperty('--text-scale', textScale ?? 1)
  document.documentElement.dataset.theme = theme === 'light' ? 'light' : 'dark'
  if (/^#[0-9a-f]{6}$/i.test(accent ?? '')) document.documentElement.style.setProperty('--accent', accent)
})
  • textScale follows the player's text size setting (1, 1.12, 1.25 or 1.38).
  • theme is 'light' or 'dark': the phone's current mode, with Automatic already worked out.
  • accent is the phone's accent colour as #rrggbb. The phone sends it; other hosts (oxide-tablet) do not, so treat it as optional.

All three are sent again whenever the player changes them. Frame widgets get the same context (see Frame widgets).

5.5 Call your own NUI callbacks

Your page is its own resource's page, so RegisterNUICallback in your Lua works as usual. Build the URL with the SDK, not GetParentResourceName() (FiveM does not provide it inside an iframe):

import { getResourceName } from './utils/host.js'

const res = await fetch(`https://${getResourceName()}/transfer`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ to, amount }),
})

5.6 Leave, or open another app

requestHome() returns to the home screen, requestClose() puts the phone away and requestOpenApp('other_app') switches app.

5.7 Phone services

callHost(method, params) asks the phone for something and resolves with the result or rejects with an error code (not_hosted, timeout, unknown_method, not_attributed, ...).

MethodParamsResult
phone.getNumberThe number of the SIM in this phone, or null
phone.setBadgecounttrue
phone.notify{ message, title?, icon?, id?, data?, sound?, toast?, link? }The notification id

The phone answers for the app on screen only; see Security. Media, sharing, links, location, accounts and the rest are in Phone services.

5.8 Test outside the game

When your page runs on its own (as your resource's own NUI, or in a plain browser), isHosted is false: the SDK sends nothing and callHost rejects with not_hosted. Keep a dev mock that feeds your page sample data in that case, and never ship code that fakes the host's messages.

6. Widgets

6.1 Register a widget

local register = olink.phone and olink.phone.RegisterWidget
if register then
    pcall(register, {
        id = 'fleeca_balance',
        label = 'Fleeca',
        icon = 'fa-solid fa-building-columns',
        color = '#3F8F7A',
        resource = GetCurrentResourceName(),
        type = 'stat',
        sizes = { 'small', 'medium' },
        app = 'fleeca_bank',
    })
end
FieldMeaning
id, label, resource, icon, color, requires, orderAs for apps
typestat, list, progress, text or frame
sizesAny of small (2 x 2 cells), medium (4 x 2) and large (4 x 4) on the phone's 4 x 6 grid. Default { 'small' }
appTapping the widget opens this app, and the widget is only offered while the app is on the phone
dataFirst data, same as SetWidgetData
url, query, readyTimeoutMsFrame widgets only

Players add widgets from the home screen's edit mode and choose the size.

6.2 Push data

SetWidgetData(id, data) on the client, SetWidgetData(src, id, data) on the server. The phone keeps the last data while it is closed, so the widget shows it at once. Data is capped at 8 KB once JSON-encoded.

TypeData
stat{ value, label?, sub?, trend? }
list{ title?, items = { { text, value?, color? }, ... }, empty? }
progress{ label?, value, max? (100), sub?, color? }
text{ title?, body }

trend is 'up', 'down', 'flat', a number (shown as a signed percentage) or { dir, text }.

6.3 Frame widgets

A frame widget loads url with appWidget=<id>&appSize=<size> instead of appId. Use the same SDK: call markReady(), and receive data with

import { onWidgetData, requestOpenApp } from './utils/host.js'

onWidgetData((data) => render(data))
tile.addEventListener('click', () => requestOpenApp())   // opens the widget's app

onWidgetData also works on oxide-tablet. Besides reporting ready, a frame widget may only ask to open an app or hand ESC to the phone. A frame widget that has not called markReady() within its readyTimeoutMs (8 s by default) shows "Unavailable".

A frame widget gets the phone's look the way an app page does: once it reports ready, the phone sends the context (theme, accent, textScale, locale), then its data, and sends the context again whenever the player changes the theme, accent or text size. Use the same onHostContext handler as your app page. A widget's safeArea is all zeros, and its size is in hostedSize (appSize).

Size and corners. The frame is exactly the widget's slot for every size, and the phone clips it to the slot's rounded corners (20 px). Your page:

  • fills 100% of the frame (html, body { margin: 0; width: 100%; height: 100%; }) and draws its own background edge to edge, without rounding its own corners;
  • keeps the root background transparent until it has painted (html { background: transparent; }), so nothing white shows while it loads;
  • leaves color-scheme unset on its root element. The frame's scheme is normal, and a page whose root scheme differs gets an opaque canvas painted behind it by the browser. Follow the phone's theme from the context instead (set color-scheme on an inner element if needed).

The page keeps running while the phone is open: opening an app, swiping pages and locking the phone do not reload it. It loads again only when the phone is opened again or the widget is added again, so keep its last data on screen until fresh data arrives.

7. Notifications and badges

-- client
olink.phone.Notify({ app = 'fleeca_bank', title = 'Transfer received', message = '$1,200 from Maze Bank', data = { tx = 81 } })
-- server
olink.phone.Notify(src, { app = 'fleeca_bank', message = 'Your loan was approved' })
FieldMeaning
appYour app id (required)
messageUp to 280 characters; longer text is cut (required)
titleUp to 80 characters
iconFont Awesome class; default the app's icon
idYour own id. Posting the same id again replaces the notification. The returned id is <app>:<id>
dataA table, up to 8 KB encoded, handed back when the player taps it
soundfalse for a silent notification
toastfalse never shows a banner; true shows it even while your app is on screen
linkOne of your own links. Tapping the notification opens your app and hands it the link through onLink

The phone stores the notification, shows a banner while the phone is open, plays a short sound and counts it on the app icon. Players can mute each app, turn banners or sound off, or use Do Not Disturb. What a locked phone shows follows the player's Show Previews setting.

Only a phone that has your app can be reached by it. On a phone where your app is not installed (and not preinstalled by the server owner), Notify returns false: no card, no sound and no badge, and SetBadge is refused the same way. Your widgets are only offered while the app is on the phone. Post notifications regardless; the phone sorts out who has the app.

When the player taps a notification, the phone opens your app, sends it the data, and fires olink:client:phone:notificationTapped. In your page:

import { onNotificationTap } from './utils/host.js'

onNotificationTap((data) => openTransaction(data.tx))

DismissNotification(id) removes one (pass the id Notify returned), ClearNotifications(appId?) all of an app's. SetBadge sets the icon number; the icon shows the larger of your badge and your waiting notifications.

8. Security

  • The phone only frames pages served by the resource that registered the app (https://cfx-nui-<resource>/...). Anything else never loads.
  • Every request from your page (callHost, requestOpenApp, ESC, ...) is taken from the iframe on screen. A service request is answered only for the app on screen, checked against the origin of the page that sent it and a per-open token the phone keeps; a field your page sends cannot claim to be another app.
  • Your page cannot drive the phone's own screens. The phone's internal callbacks refuse any call that does not carry a token only the phone's own page has.
  • Your page runs in a sandboxed iframe: scripts, its own origin (so its NUI callbacks and storage work), forms and pointer lock are allowed. It cannot navigate the phone, open popups or show alert/confirm dialogs; draw your own confirmations.
  • Only other resources can register apps and widgets through o-link; a registration that names the phone itself as its resource is refused. Phone services need the app id declared in the owning resource's manifest (phone_app), so no other resource can pose as your app.
  • Treat everything your page sends to your own Lua as player input: check it on the server.
  • Phone services (10) are answered for your app on the phone in the player's hand only. Your app gets the photos and videos the player chose for it and nothing else, and its accounts, permissions, media and evidence are its own: no other app's page or server can read them.

9. Common mistakes

  • Registering once at start only. The phone forgets registrations when it restarts; register on olink:client:phone:ready too.
  • Comparing type(olink.phone.RegisterApp) == 'function'. It is a table across the resource boundary; nil-check and pcall it.
  • Never calling markReady(). The player sees a loading screen, then an error.
  • Relying on GetParentResourceName() inside the iframe. Use getResourceName() from the SDK.
  • Loading the SDK from the phone's folder at runtime. Copy it into your own bundle.
  • Putting controls under the status bar or the home bar.
  • Using alert() or confirm(). The sandbox blocks them.
  • Sending a media address from your page to your server and storing it as it is. Send the item's id and check it with media.get.
  • Using a permission you did not declare in permissions. It fails with not_declared.
  • Recording evidence from your page. Only your server can, with evidence.record.

10. Phone services

Your app can use the phone's own photos, camera, storage, share sheet, location and accounts, and leave evidence on the phone the way the built-in apps do. Each service has a page side (callHost) and, where your server needs it, a server side (olink.phone.AppService, see 10.7).

Declare each app id your resource owns in its fxmanifest.lua, with the name extraction reports show for it. Without it the phone offers your app none of these services:

fxmanifest.lua
phone_app 'snapgram' { label = 'Snapgram' }

Every request is answered for one app on one phone: your page is answered only while your app is on screen, for the phone in the player's hand; your server only for the app ids its manifest declares, and for a player only while your app is on that player's phone. The server checks every request and limits how often each one can be made.

A phone that police hold as evidence answers reads but takes no changes. account.current, account.get, device.info and device.get then carry readOnly = true: show the app, but take no posts, messages or changes from that phone.

10.1 Permissions

Photos, the Camera, the Microphone and Location need the player's permission. Declare what your app asks for when you register it, each with the sentence the prompt shows:

permissions = {
    photos = 'Share photos from your library in posts.',
    camera = 'Take photos and videos for posts.',
    microphone = 'Talk to your viewers when you go live.',
    location = 'Show how far away other people are.',
},

The first time your app uses one of them, the phone asks ("Snapgram" Would Like to Access Your Photos). The answer is kept for that phone, and the player can change it later in Settings, under your app's name. A permission you did not declare is never asked and fails with not_declared.

MethodParamsResult
permission.status{ permission }{ permission, state, precise? }, state is allowed, denied or undetermined. Never asks
permission.request{ permission }The same, after asking if the player was never asked

A refused request rejects with permission_denied. You do not need to ask first: media.pick, media.capture and location.get ask by themselves. Ask first when you want to explain it in your own screen before the prompt.

10.2 Media

MethodParamsResult
media.config{ ready, reason?, maxPick, photo = { maxBytes, types }, video = { maxBytes, maxMs, types, posterTypes } }
media.pick{ max?, kinds? }Up to max (1 to 10, default 1) items chosen in the phone's Photos picker; kinds is ['photo'], ['video'] or both (default). An empty list when the player cancels
media.capture{ mode? }The phone's Camera opens in photo (default) or video mode; the item the player uses, or null when they cancel
media.upload{ file, kind?, poster?, durationMs? }A photo or video your app made (a Blob), stored through the phone's storage

Each answer is a media item:

FieldMeaning
idThe item's number for your app on this phone. Send this to your server, not the address
sourcepicked, camera or upload
kindphoto or video
urlIts public address. It stays valid
posterA video's still image
mime, size, width, height, durationMsWhat the phone knows about the file
import { callHost, getResourceName } from './utils/host.js'

const [photo] = await callHost('media.pick', { max: 1, kinds: ['photo'] })
if (photo) await fetch(`https://${getResourceName()}/post`, { method: 'POST', body: JSON.stringify({ media: photo.id }) })
  • Picking shows the player's own library. Your app gets only what the player chose, never more than max, and only of the kinds you asked for.
  • Capturing uses the phone's own Camera. What the player takes is also kept in their Photos, as everything the Camera takes is.
  • Uploading stores a file your app made (a filtered picture, an edited clip). Photos are WebP, JPEG or PNG; videos are WebM with a poster (the phone makes one from the first frame when you send none) and need a length (durationMs, read from the file when you leave it out). Sizes and lengths are the server's Media settings; media.config tells you them. Uploads never appear in the player's Photos.
  • Storage set to none makes these fail with not_configured.

Your page's word is not proof. Before your server stores an address, check the item with media.get (see 10.7): it answers only for items given to your app on that player's phone.

Sharing. share.open opens the phone's share sheet for something in your app:

ParamsMeaning
textUp to 1000 characters
urlA link, such as one of your own (snapgram://post/42)
titleThe sheet's heading. Default: your app's name
mediaA media item id your app got. A photo from Photos is shared as the photo; anything else as its address, with Save to Photos

It resolves when the sheet closes with { completed, via? }, via being messages, nearby, copy, photos or the app it was opened in.

Links. Register the link patterns your app opens:

links = { 'snapgram://post/:id', 'snapgram://user/:name', 'snapgram://live/*' },

A pattern is a scheme of your own, ://, then path parts: plain words, named parts (:id) and a final * for the rest. The first app to register a scheme keeps it; the web's schemes (http, https, mailto, tel, ...) are never taken. A link that fits opens your app on the screen it names, from:

  • a Messages bubble (the link in the text is tappable),
  • a notification of yours with link set,
  • the share sheet's text sent into Messages,
  • another app calling links.open with { url } (the phone asks the player first; a link into your own app opens at once).
import { onLink } from './utils/host.js'

onLink(({ params, query, source }) => openPost(params.id))

onLink receives { url, path, params, query, source }, source being messages, notification, share or app. A link shows as a link only on phones that have your app; elsewhere it is plain text, and links.open answers not_installed. Anyone can write a link: treat params and query as player input, and never act on a link without the player.

10.4 Device and location

MethodParamsResult
device.info{ id, number?, readOnly? }: the phone's id and its SIM's number. No permission
location.getWhere the phone is; needs Location

With precise location the answer is { x, y, z, accuracy = 5, precise = true, zone?, street? }. Without it, the position is on a 250 m grid with no height and no street: { x, y, accuracy = 250, precise = false, zone? }. The position is the server's own; the area and street names come from the player's game and count only where the server sees the player.

10.5 Accounts

The phone keeps one account model for every app, so you do not build sign-in yourself. You keep your own content tables, keyed by the account id.

  • The first time your app opens on a phone with a SIM, the phone logs in to the account of that SIM's number, made then with a random name like user482913. Once that account has a password, a phone with the SIM must log in with it (a SIM is not a way into someone's account). A phone with no SIM starts logged out.
  • A player can log out, make another account, or log in with a username and password, so an account moves between phones like a real app's. A phone that logged out stays logged out.
  • An account made for a SIM has no password until the player sets one.
MethodParamsResult
account.current{ id, username, displayName?, hasPassword, readOnly? } or null (logged out)
account.login{ username, password }The account
account.logouttrue
account.create{ username, password, displayName? }The new account, logged in
account.update{ username?, displayName? }The account
account.setPassword{ current?, password, signOutOthers? }true; current is needed once it has one. Every other phone signed in to the account is signed out unless signOutOthers is false
account.devicesThe phones signed in to the account: { id, number?, current?, since }, current being this phone
account.signOut{ device }true; signs that phone out (never this one: invalid; not signed in: not_found)

Usernames are 3 to 24 of a-z, 0-9, _ and . (stored in lower case), unique per app. Passwords are 6 to 64 characters and stored as salted hashes. Five wrong passwords in a row from one phone lock the account on that phone for five minutes. Errors: username, password, taken, wrong, locked_out, logged_out.

Signed out from elsewhere. A phone can be signed out without anyone touching it: by a password change on another phone, by Signed-In Phones (in your app through account.signOut, or in the phone's Settings under your app), or because its owner reported the number's SIM lost. If your app is open on that phone, the phone restarts it, so your page starts as it does logged out (account.current answers null); your server's account.get answers nil for that player. Every login change also fires the server event olink:server:phone:account with (appId, { deviceId, src?, account? }): if your server caches accounts per player, drop src's entry there.

AddEventHandler('olink:server:phone:account', function(appId, event)
    if appId == 'snapgram' and event.src then AccountCache[event.src] = nil end
end)

Players can change the password and sign phones out in the phone's own Settings too, so offering it in your app is optional. If you do, show Sign out of all other phones on by default and send it as signOutOthers.

10.6 Evidence

The phone is evidence: what a player does in your app can be found on their phone. Record it from your server (a page could forge it or leave it out), as it happens:

MethodParamsRecords
evidence.record{ src, item, direction? }On the phone of src, which shows your app. direction out (default)
evidence.recordFor{ account, item, direction?, by? }On every phone logged in to that account (something it received). direction in (default); by is the account that caused it, such as a message's sender
evidence.recordBatch{ records }Up to 100 records in one call, each { src, item, direction? } or { account, item, direction?, by? }

An item is:

FieldRequiredMeaning
kindyespost, comment, message, match, stream, stream_end, like, follow, profile, edit or delete
idyesYour own id for it, up to 64 characters
parentfor edit and deleteThe id it answers, belongs to, or changes
textnoUp to 2000 characters
medianoUp to 10 { url, kind?, poster? } on the phone's storage hosts
peernoThe other party: the account a message went to or came from, the match
metanoUp to 10 plain values (text, numbers, true or false), 1 KB in all

What to send:

  • A post, comment, like, follow or profile change: evidence.record with that kind.
  • A message: evidence.record for the sender (peer = recipient), evidence.recordFor for the recipient's account (peer = sender, by = the sender's account id).
  • A match: recordFor on both accounts, each with the other as peer.
  • A live stream through stream.start: nothing. The phone records it (see Live streams). Record a stream only when your app streams another way.
  • An edit: kind = 'edit', parent = the item, text = the new words. The phone keeps every version.
  • A delete: kind = 'delete', parent = the item. The item and its edits stay findable by police for the server's recovery window, then never again.

Each record keeps the account the phone was logged in to and where the phone was. Forensics extractions show them in an Apps part of the report, one section per app, with the media your app was given or uploaded, under the same recovery window and chain of custody as the phone's own content. Erase All Content deletes them the way the phone's apps delete things (still found inside the window); a secure wipe removes them for good. Apps cannot change or read back records.

Pacing. Recording is limited per source, never per app, so evidence from normal play is never lost and one player flooding your app cannot crowd out anyone else:

  • evidence.record is paced per phone. evidence.recordFor is paced per sender when you pass by, else per receiving account.
  • Each source may record 60 at once, then 6 a second. Past that, records wait in a queue of up to 300 per source and are written as the allowance refills, each with the time and place it happened. The call then answers 'queued' instead of the record's number.
  • Only a source that overflows its queue is refused, with rate_limited. The server log names the app and the source.
  • In evidence.recordBatch, each entry is checked and paced on its own. The answer is { recorded, queued, failed = { { index, reason }, ... } }. An entry with src needs your app on that player's phone, as evidence.record does.

10.7 Your server: olink.phone.AppService

local ok, result = olink.phone.AppService('snapgram', 'account.get', { src = source })
if not ok then return end   -- result is the reason

Only the resource whose manifest declares an app id (phone_app, see 10) may use it; any other is refused with not_owner. Call it from your own resource: o-link tells the phone who called. Methods that take src answer only while your app is on that player's phone (not_on_screen). On a server running another phone, the answer is false, 'unsupported'.

MethodParamsResult
device.get{ src }{ id, number?, readOnly? }
location.get{ src }As on the page (needs Location; no area names)
account.get{ src }The account that phone is logged in to (with readOnly on a phone police hold), or nil
account.find{ id } or { username }{ id, username, displayName? } or nil
account.players{ id }Server ids of players carrying a phone logged in to it, for notifications
account.open{ src }The account the player's phone that is out is logged in to, or false (none, or no phone out)
media.get{ src, id }The media item, if your app got it on that phone; else nil
evidence.record{ src, item, direction? }The record's number, or 'queued'
evidence.recordFor{ account, item, direction?, by? }How many phones it was recorded on, or 'queued'
evidence.recordBatch{ records }{ recorded, queued, failed }
stream.listYour app's live streams, each with viewerAccounts, streamerSrc and streamerDeviceId
stream.get{ id }One of them with viewerAccounts, streamerSrc and streamerDeviceId, or nil
stream.startedBy{ id, src }true when that player's phone started that stream of your app, else false
stream.end{ id }true; viewers are told it ended with reason app
stream.setAdmission{ check }true. Your check on who may watch your app's streams; check = false removes it. See Live streams
stream.kick{ id, account, reason? }How many phones were removed from that stream

10.8 Live streams

A player can go live from your app with the phone's camera, and anyone with your app can watch from anywhere on the map, a second or two behind. The video goes through the game server, so there is no media server to run.

Go live. stream.start asks for the Camera (and the Microphone when you pass mic: true), then shows the phone's camera full screen under your page, with the phone's LIVE bar at the top (time, viewers, microphone, flip, End). Your page stays on top: make its background transparent while live so the camera shows through, and draw your own overlay (comments, hearts). While live, the context's safeArea.top grows by the bar's height.

import { callHost, onStreamEvent } from './utils/host.js'

document.body.classList.add('live')   // your CSS: body.live { background: transparent }
const stream = await callHost('stream.start', { title: 'Night market tour', camera: 'selfie', mic: true })
onStreamEvent((e) => {
  if (e.type === 'join') showJoined(e.account)    // the streamer hears who joined
  if (e.type === 'ended') document.body.classList.remove('live')
})
// later
await callHost('stream.end', { id: stream.id })

The player's filter and front or back camera work as in the Camera app. Without the Microphone permission, or without mic, the stream has no sound. A stream ends by itself when the player puts the phone away, leaves your app, dies, logs out or disconnects, when the phone locks or police hold it, when no usable video reaches the server for 10 seconds (no_video), or at the time limit. The server log has one line for every end, with the reason and how many chunks arrived or were refused.

Watch. watchStream(id, video) from the SDK joins a stream and plays it in a <video> of your own page:

import { callHost, watchStream } from './utils/host.js'

const [first] = await callHost('stream.list')
const watch = await watchStream(first.id, document.querySelector('video'))
watch.info.viewers   // the count as you joined
watch.stats()        // { chunks, bytesPerSecond, arrivalMs, bufferMs }
await watch.stop()   // leave

The player keeps up with live: when it falls more than 3 seconds behind, it jumps ahead. A player watches one stream at a time; joining another leaves the first. Leaving your app leaves the stream. When your app turns a viewer away, watchStream rejects with denied and your reason in the error's reason (see Who may watch below).

MethodParamsResult
stream.start{ title?, camera?, mic? }The stream. camera is rear (default) or selfie; title up to 80 characters
stream.end{ id }true. Only the player who started it
stream.watch{ id }The stream. watchStream calls it for you
stream.leave{ id }true
stream.listYour app's live streams
stream.get{ id }One of them, or null

A stream is { id, app, title?, camera, mic?, account?, startedAt, viewers, peak }. account is { id, username } of the account the streamer's phone was logged in to; startedAt is in milliseconds. Streams belong to an app: yours sees and controls only its own, and another app's id answers not_found.

Events. onStreamEvent(cb) receives { id, app, type, viewers?, account?, reason? } for the streams the player is in:

  • started, to the streamer.
  • join and leave, with the new viewers count. The streamer also gets the viewer's account.
  • ended, with reason: stopped (the streamer ended it), why the streamer's phone was put away (closed, idle, dead, dropped, flat, powered_off, unloaded, custody, erased), locked, app_closed, no_video (no usable video for 10 seconds), time_limit, too_fast, admin, app or app_stopped.
  • kicked, to a viewer your app removed, with your reason. Their player stops.

Your server gets every event, with the viewer's account and the streamer's streamerSrc and streamerDeviceId, as a server event. Listen with AddEventHandler (it is not a network event):

AddEventHandler('olink:server:phone:stream', function(appId, event)
    if appId ~= 'snapgram' then return end
    if event.type == 'join' and event.account then Popularity.Add(event.id, event.account.id) end
end)

Use stream.list and stream.get from your server (see 10.7) for who is watching right now, such as to rank streams by real viewers.

Bind a stream to the phone that started it. When a page tells your server "this is my stream", check it came from that player's own phone, not only from their account: two phones can be logged in to one account, and either could name the other's stream. Your server's stream.get and stream.list carry streamerSrc (the streamer's server id) and streamerDeviceId (their phone); pages never get them. Or ask the phone directly:

local ok, mine = olink.phone.AppService('snapgram', 'stream.startedBy', { id = streamId, src = source })
if not (ok and mine) then return end   -- not this player's own stream

Who may watch

By default anyone with your app can watch any of its streams. To decide yourself (a private account's followers only, banned users kept out), register a check from your server:

olink.phone.AppService('snapgram', 'stream.setAdmission', {
    check = function(streamId, viewerAccountId, streamerAccountId)
        if Bans.Has(viewerAccountId) then return false, 'banned' end
        if Profiles.IsPrivate(streamerAccountId) and not Follows.Approved(viewerAccountId, streamerAccountId) then
            return false, 'private'
        end
        return true
    end,
})
  • The phone calls your check before each viewer joins, and again when a viewer's phone logs in to another account (or out) while watching. viewerAccountId is nil for a phone logged out of your app.

  • Return true to let the viewer in. Anything else turns them away with false and a reason: a code of up to 32 letters, digits, _ or - (anything else becomes denied). Their page's stream.watch (and watchStream) rejects with the error denied and your code in reason:

    try {
      await watchStream(id, video)
    } catch (e) {
      if (e.message === 'denied') showNotice(e.reason)   // 'private', 'banned', ...
    }

    A viewer whose new account is turned away is removed with a kicked event carrying your reason.

  • Your check may wait (a database query). If it has not answered after about a second, or it raises an error, the viewer is let in and the server log says so. Return false yourself where letting someone in by mistake matters.

  • One check per app; registering again replaces it, and check = false removes it. It ends when your resource stops. Only the resource that declares the app id can set it.

Removing a viewer. stream.kick takes an account off one of your app's streams, such as when you ban it:

olink.phone.AppService('snapgram', 'stream.kick', { id = streamId, account = accountId, reason = 'banned' })

Every phone watching on that account leaves the stream. That account and those phones cannot join that stream again; they are turned away with your reason. The viewer's page gets a kicked event with the reason, the streamer sees them leave, and the answer is how many phones were removed. Only your app's own streams can be kicked from (not_found otherwise).

Limits. Set in /phone settings → Media → Live streams (Config.Streams):

SettingDefaultMeaning
enabledtrueOff refuses every stream with disabled
maxViewers25Viewers per stream. The next one is refused with stream_full
maxStreams4Streams live at once on the server, every app together. The next is refused with streams_full
kbps500Video bitrate
fps20Frames per second
egressMbps40Upload the server may spend on streams. A viewer who would pass it is refused with server_busy
maxMinutes60Longest stream

The phone tells the player why in its own words; your page gets the error code. Each viewer counts as the bitrate sent as base64 plus a tenth for sound and events (kbps * 1000 / 8 * 4 / 3 * 1.1 bytes a second), or what the stream really sends when that is more: about 0.73 Mbps at the default 500 kbps, so 25 viewers of one stream count as about 18 Mbps against egressMbps.

Evidence. The phone records live streams itself: stream on the streamer's phone when it starts (title, camera, microphone), stream (in, peer = the streamer) on each viewer's phone, and stream_end on the streamer's phone with how long it ran, the most viewers at once, how many watched in all, who watched, and why it ended. The video itself is not kept.

Admins. /phone streams lists live streams (app, player, account, viewers, minutes) and the upload in use; /phone endstream <id> ends one.

A media server. exports['oxide-phone']:SetStreamProvider(hooks) lets one resource follow the lifecycle: Admit(stream, viewer) may return false to refuse a stream (viewer is nil) or a viewer (error refused); Started(stream), Joined(stream, viewer), Left(stream, viewer) and Ended(stream, reason) are told. The video still goes through the server; the hooks only follow the lifecycle, for example to keep a media server of your own in step.

10.9 Errors

CodeMeaning
not_hostedThe page runs outside the phone
timeoutNo answer in time (10 s, or 10 minutes for the services that wait on the player)
unknown_methodNo such service
invalidA parameter is missing or wrong
lockedThe phone is locked, put away, or (for changes) held by police
not_on_screenYour app is not the one on that phone
not_attributedThe request did not come from your app's own page
not_ownerYour resource does not declare that app id
unknown_link, not_installed, cancelledlinks.open: no app opens the link, its app is not on the phone, or the player said no
permission_denied, not_declaredSee Permissions
rate_limitedToo many requests; try again shortly
busyThe picker or the Camera is already open for your app
stream_full, streams_full, server_busy, already_live, disabled, self, refused, not_foundLive streams: the stream has its most viewers, the server its most streams or upload, the player is already live, streams are off, watching your own stream, refused by the stream provider, no such stream of your app
deniedLive streams: your app's check turned the viewer away, or your app removed them. The error's reason is your code
not_configured, size, duration, type, poster, upload, missing, fullMedia: storage off, the file too big or too long, an unknown type, a bad poster, the upload failed, storage does not hold it, or your app's 500 items on that phone are used

10.10 Try it

With Debug mode on (/phone settings → Advanced), the Store offers the Demo app (web/dist/demo.html in the phone's folder), which calls every service above and has its own icon. To try live streams, open the Demo app on two players' phones, press Go live on one and Watch first on the other.

Custom photo storage

With Storage set to Custom in /phone settings → Media, the phone asks your resource for an upload address each time a player saves a photo, video or voice clip (a video asks twice: once for the clip and once for its poster image). Register a function from your server script:

exports['oxide-phone']:SetMediaProvider(function(request)
    -- request = { deviceId, kind = 'photo' | 'video' | 'audio', size = bytes,
    --             contentType = 'image/webp' | 'image/jpeg' | 'image/png' | 'video/webm' | 'audio/webm' }
    local upload = MyStorage.CreateUpload(request.contentType, request.size)
    return {
        method = 'PUT',                 -- or 'POST' (multipart form)
        url = upload.url,               -- one-time upload address
        fileField = 'file',             -- POST only: the form field for the image
        headers = { ['Content-Type'] = request.contentType },
        publicUrl = upload.publicUrl,   -- where the photo will be served from
    }
end)

The player's game uploads the file straight to url. Without publicUrl, the upload response must be JSON with url or data.url. The phone then checks that the address is https on one of the Allowed hosts (Media settings), and that it answers with the right kind of file (a PNG, JPEG or WebP image; a WebM video; a WebM voice clip) no larger than that kind's limit, before it appears on the phone. Video uploads ask for progress, so storage that takes them by POST from the browser should answer CORS preflight requests; the phone falls back to a plain upload when it doesn't. Your function runs on the server; keep any storage secrets there. The registration ends when your resource stops.