ADD - calls app and sim cards

This commit is contained in:
Eichenholz
2026-08-05 17:43:42 +02:00
parent 1bec705605
commit 13ddeb893b
31 changed files with 2470 additions and 54 deletions
+4 -1
View File
@@ -2,13 +2,16 @@
Standalone FiveM phone built with Vue 3, TypeScript, Pinia, Vue Router, Konsta UI 5, and Tailwind CSS 4. Each non-stackable `sky_phone` item receives a unique 15-digit IMEI and owns its server-persisted device state. The phone opens through the usable item; `/phone` is disabled unless `Config.Phone.DevelopmentCommand` is enabled explicitly.
An iFruit account is optional. Unlinked devices retain local settings, alarms, media, apps, and notes. Linking from Mail or Settings moves local notes into the account and exposes the same notes and mailbox on every linked device. Signing out hides cloud data without deleting it, while a factory reset removes only the current device's local data and account link.
An iFruit account is optional. Unlinked devices retain local settings, alarms, media, apps, notes, contacts, and recent calls. Linking from Mail or Settings moves local data into an empty cloud account; an existing cloud dataset wins over local contacts and recents. Signing out keeps an editable local snapshot without deleting cloud data.
## Requirements
- `sky_base` with a slot-aware inventory adapter implementing `GetInventorySlot`, `GetInventorySlotsWithItem`, and `SetInventorySlotMetadata`.
- `sky_jobs_base` for the authoritative character identifier captured by registered SIM cards.
- A non-stackable inventory item named `sky_phone`.
- Two unique, non-stackable inventory items named `sky_phone_sim_registered` and `sky_phone_sim_anonymous`. Their metadata is initialized automatically on first use, so shops and crafting recipes add plain items without supplying a number.
- MySQL/MariaDB through the database driver configured in `sky_base`.
- `pma-voice` when `Config.Calls.VoiceProvider` is set to `"pma"`.
Database migrations run automatically. Existing `sky_phone_mail_accounts` installations are renamed to `sky_phone_accounts` while preserving account IDs and mail foreign keys. iFruit passwords are intentional in-character credentials and remain plaintext `VARCHAR(64)` values; registration screens warn players never to reuse a real password.
+67 -4
View File
@@ -15,8 +15,12 @@ import PhoneLockScreen from '@/components/PhoneLockScreen.vue'
import PhoneNotifications from '@/components/PhoneNotifications.vue'
import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue'
import PhoneStatusBar from '@/components/PhoneStatusBar.vue'
import SimPhonePicker, {
type SimPhoneChoice,
} from '@/components/SimPhonePicker.vue'
import { PHONE_FRAME_IMAGES } from '@/config/appearance'
import { useClockStore } from '@/stores/clock'
import { useCallsStore } from '@/stores/calls'
import { useAccountStore } from '@/stores/account'
import { useMailStore } from '@/stores/mail'
import { useMediaStore } from '@/stores/media'
@@ -28,6 +32,7 @@ import {
import { usePhoneStore, type PhoneOpenPayload } from '@/stores/phone'
import type { PhoneNotificationDevicePayload } from '@/types/device'
import type { MailCounts } from '@/types/mail'
import type { PhoneCall } from '@/types/phone'
import { nuiCall } from '@/utils/nui'
import { formatTimer } from '@/utils/clock'
import { parsePhonePreferences } from '@/utils/preferences'
@@ -35,7 +40,12 @@ import SpringboardView from '@/views/SpringboardView.vue'
type AppMessage = {
type?: string
data?: MailEventData | PhoneNotificationInput | PhoneOpenPayload
data?: MailEventData | PhoneCall | PhoneNotificationInput | PhoneOpenPayload
}
type SimPickerPayload = {
choices: SimPhoneChoice[]
number: string
}
type MailEventData = {
@@ -55,6 +65,7 @@ const isDevelopment = import.meta.env.DEV
const phone = usePhoneStore()
const account = useAccountStore()
const clock = useClockStore()
const calls = useCallsStore()
const mail = useMailStore()
const media = useMediaStore()
const notes = useNotesStore()
@@ -64,6 +75,7 @@ const router = useRouter()
const isAppRoute = computed(() => route.name === 'app')
const isLocked = ref(false)
const isUnlocking = ref(false)
const simPicker = ref<SimPickerPayload | null>(null)
const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
const viewportScale = ref(getViewportScale())
const phoneBaseZoom = computed(() => viewportScale.value * PHONE_BASE_SCALE)
@@ -94,6 +106,7 @@ function hydratePhone(payload: PhoneOpenPayload): void {
clock.hydrate(payload.device?.data.alarms?.payload)
media.hydrate(payload.device?.data.media?.payload)
void mail.bootstrap(payload.account?.email ?? '')
void calls.bootstrap()
}
function onMessage(event: MessageEvent<AppMessage>): void {
@@ -136,6 +149,23 @@ function onMessage(event: MessageEvent<AppMessage>): void {
}
}
notifications.show(notification)
} else if (event.data?.type === 'contacts:changed') {
void calls.loadContacts()
} else if (event.data?.type === 'calls:changed') {
void calls.loadRecents()
} else if (
(event.data?.type === 'call:incoming' ||
event.data?.type === 'call:state') &&
event.data.data
) {
calls.applyCallState(event.data.data as PhoneCall)
isLocked.value = false
isUnlocking.value = false
window.setTimeout(() => void router.push('/apps/phone'), 0)
} else if (event.data?.type === 'sim:picker' && event.data.data) {
simPicker.value = event.data.data as unknown as SimPickerPayload
} else if (event.data?.type === 'sim:picker-close') {
simPicker.value = null
}
}
@@ -210,17 +240,43 @@ onMounted(() => {
data: {},
imei: '356938035643809',
name: 'iFruit Phone',
sim: {
id: 'development-sim',
number: '5551234567',
registered: true,
type: 'registered',
},
},
notes: [],
token: 'development',
})
if (new URLSearchParams(window.location.search).has('simPickerPreview')) {
simPicker.value = {
choices: [
{
imei: '356938035643809',
name: 'Personal Phone',
occupied: false,
},
{
imei: '356938035643810',
name: 'Work Phone',
number: '5559876543',
occupied: true,
},
],
number: '5551234567',
}
}
}
})
watch(
() => notifications.requiresAttention,
(active) => {
void nuiCall('notification:focus', { active })
[() => notifications.requiresAttention, () => calls.activeCall],
([requiresAttention, activeCall]) => {
void nuiCall('notification:focus', {
active: requiresAttention || activeCall !== null,
})
},
)
@@ -253,11 +309,18 @@ onBeforeUnmount(() => {
</script>
<template>
<SimPhonePicker
v-if="simPicker"
:choices="simPicker.choices"
:number="simPicker.number"
@close="simPicker = null"
/>
<Transition name="phone-lift" appear>
<main
v-if="
phone.isOpen ||
notifications.current ||
calls.activeCall ||
notifications.devicePreviews.length
"
class="phone-stage"
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+197
View File
@@ -1,6 +1,190 @@
@import 'tailwindcss';
@import 'konsta/vue/theme.css';
.sim-picker-backdrop {
position: fixed;
z-index: 1000;
inset: 0;
display: grid;
place-items: center;
background: rgb(0 0 0 / 45%);
pointer-events: auto;
}
.sim-picker {
position: relative;
width: min(58vh, 90vw);
max-height: 46vh;
overflow: clip;
padding: 2.2vh;
border: 0.1vh solid rgb(255 255 255 / 12%);
border-radius: 0.9vh;
background: #050505;
box-shadow: 0 2.4vh 8vh rgb(0 0 0 / 55%);
color: #fff;
}
.sim-picker__glow {
position: absolute;
border-radius: 50%;
background: #1dd1ce;
filter: blur(12vh);
opacity: 0.45;
pointer-events: none;
}
.sim-picker__glow--top {
width: 18vh;
height: 18vh;
top: -12vh;
right: -8vh;
}
.sim-picker__glow--bottom {
width: 32vh;
height: 16vh;
right: 10vh;
bottom: -16vh;
}
.sim-picker__header,
.sim-picker__card,
.sim-picker__confirmation {
position: relative;
z-index: 1;
}
.sim-picker__header {
display: flex;
justify-content: space-between;
gap: 2vh;
margin-bottom: 1.6vh;
}
.sim-picker__header h1,
.sim-picker__confirmation h2 {
margin: 0;
color: #1dd1ce;
font-size: 2vh;
}
.sim-picker__header p,
.sim-picker__confirmation p {
margin: 0.45vh 0 0;
color: #9ca3af;
font-size: 1.15vh;
}
.sim-picker__close {
display: grid;
place-items: center;
width: 2.8vh;
height: 2.8vh;
border: 0;
border-radius: 0.35vh;
background: #1dd1ce;
color: #050505;
cursor: pointer;
}
.sim-picker__close svg {
width: 1.6vh;
height: 1.6vh;
}
.sim-picker__cards {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1vh;
max-height: 34vh;
overflow: auto;
}
.sim-picker__card {
display: flex;
align-items: center;
gap: 1vh;
min-width: 0;
padding: 1.2vh;
border: 0.1vh solid rgb(143 143 143 / 37%);
border-radius: 0.6vh;
background: #050505;
color: #fff;
text-align: left;
cursor: pointer;
}
.sim-picker__card:hover {
border-color: rgb(255 255 255 / 50%);
}
.sim-picker__phone-icon,
.sim-picker__card > svg:last-child {
flex: 0 0 auto;
width: 2.2vh;
height: 2.2vh;
}
.sim-picker__phone-icon {
color: #1dd1ce;
}
.sim-picker__details {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 0.25vh;
}
.sim-picker__details strong {
overflow: hidden;
font-size: 1.2vh;
text-overflow: ellipsis;
white-space: nowrap;
}
.sim-picker__details small {
color: #9ca3af;
font-size: 0.95vh;
}
.sim-picker__confirmation {
padding: 2vh 0 0;
text-align: center;
}
.sim-picker__confirmation > div {
display: flex;
justify-content: center;
gap: 1vh;
margin-top: 2vh;
}
.sim-picker__confirmation button {
padding: 0.8vh 1.6vh;
border: 0.1vh solid rgb(255 255 255 / 18%);
border-radius: 0.45vh;
background: #151515;
color: #fff;
cursor: pointer;
}
.sim-picker__confirmation button.is-primary {
border-color: #1dd1ce;
background: #1dd1ce;
color: #050505;
}
.sim-picker__error {
position: relative;
z-index: 1;
color: #ff453a;
font-size: 1vh;
text-align: center;
}
:root {
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue',
@@ -945,6 +1129,19 @@ button {
overflow-y: auto;
padding: 0;
}
.phone-call-content {
min-height: 0;
flex: 1;
overflow-y: auto;
}
.phone-calls-app {
display: flex;
flex-direction: column;
}
.phone-calls-app.phone-app--light {
background: #f2f2f7;
color: #000;
}
.clock-konsta-list {
margin-top: 0;
margin-bottom: 0;
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { ChevronRight, Smartphone, X } from 'lucide-vue-next'
import { ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
import { nuiCall } from '@/utils/nui'
import { formatPhoneNumber } from '@/utils/phone'
export type SimPhoneChoice = {
imei: string
name: string
number?: string | null
occupied: boolean
}
const props = defineProps<{
choices: SimPhoneChoice[]
number: string
}>()
const emit = defineEmits<{ close: [] }>()
const phone = usePhoneStore()
const confirmation = ref<SimPhoneChoice | null>(null)
const error = ref('')
async function insert(
choice: SimPhoneChoice,
confirmed = false,
): Promise<void> {
const response = await nuiCall('sim:insert', {
confirmed,
imei: choice.imei,
})
if (response.success) {
emit('close')
return
}
if (response.error === 'confirmation_required') {
confirmation.value = choice
return
}
error.value = phone.t(`Apps.phone.errors.${response.error ?? 'default'}`)
}
function close(): void {
void nuiCall('sim:picker-close')
emit('close')
}
</script>
<template>
<div class="sim-picker-backdrop" @click.self="close">
<section class="sim-picker" aria-modal="true" role="dialog">
<div class="sim-picker__glow sim-picker__glow--top" />
<div class="sim-picker__glow sim-picker__glow--bottom" />
<header class="sim-picker__header">
<div>
<h1>{{ phone.t('Apps.phone.choosePhone') }}</h1>
<p>
{{
phone.t('Apps.phone.choosePhoneBody', {
number: formatPhoneNumber(props.number),
})
}}
</p>
</div>
<button
type="button"
class="sim-picker__close"
:aria-label="phone.t('Common.close')"
@click="close"
>
<X />
</button>
</header>
<div v-if="!confirmation" class="sim-picker__cards">
<button
v-for="choice in choices"
:key="choice.imei"
type="button"
class="sim-picker__card"
@click="insert(choice)"
>
<Smartphone class="sim-picker__phone-icon" />
<span class="sim-picker__details">
<strong>{{ choice.name }}</strong>
<small>{{
choice.occupied && choice.number
? formatPhoneNumber(choice.number)
: phone.t('Apps.phone.emptyPhone')
}}</small>
<small>IMEI {{ choice.imei }}</small>
</span>
<ChevronRight />
</button>
</div>
<div v-else class="sim-picker__confirmation">
<h2>{{ phone.t('Apps.phone.replaceTitle') }}</h2>
<p>{{ phone.t('Apps.phone.replaceBody') }}</p>
<div>
<button type="button" @click="confirmation = null">
{{ phone.t('Common.cancel') }}
</button>
<button
type="button"
class="is-primary"
@click="insert(confirmation, true)"
>
{{ phone.t('Common.done') }}
</button>
</div>
</div>
<p v-if="error" class="sim-picker__error">{{ error }}</p>
</section>
</div>
</template>
+9 -9
View File
@@ -8,16 +8,16 @@ describe('app registry', () => {
expect(PHONE_APPS.every((app) => app.route === `/apps/${app.id}`)).toBe(
true,
)
expect(
PHONE_APPS.filter((app) => app.id !== 'map').every((app) =>
app.iconImage.endsWith('.webp'),
),
).toBe(true)
expect(PHONE_APPS.find((app) => app.id === 'map')?.iconImage).toMatch(
/^(data:image\/svg\+xml|.+\.svg$)/,
expect(PHONE_APPS.every((app) => app.iconImage.endsWith('.webp'))).toBe(
true,
)
expect(PHONE_APPS.find((app) => app.id === 'phone')).toMatchObject({
dockOrder: 0,
labelKey: 'Apps.phone.name',
route: '/apps/phone',
})
expect(PHONE_APPS.find((app) => app.id === 'mail')).toMatchObject({
gridOrder: 3,
gridOrder: 4,
labelKey: 'Apps.mail.name',
route: '/apps/mail',
})
@@ -25,6 +25,6 @@ describe('app registry', () => {
PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
.map((app) => app.id),
).toEqual(['app-store', 'calculator', 'camera', 'clock'])
).toEqual(['phone', 'calculator', 'camera', 'clock'])
})
})
+25 -10
View File
@@ -6,6 +6,7 @@ import {
Mail,
MapPinned,
NotebookPen,
Phone,
Settings,
ShoppingBag,
} from 'lucide-vue-next'
@@ -19,16 +20,30 @@ import mailIcon from '@/assets/img/app-icons/mail.webp'
import mapIcon from '@/assets/img/app-icons/map.webp'
import notesIcon from '@/assets/img/app-icons/notes.webp'
import photosIcon from '@/assets/img/app-icons/gallery.webp'
import phoneIcon from '@/assets/img/app-icons/phone.webp'
import settingsIcon from '@/assets/img/app-icons/settings.webp'
import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps'
export const PHONE_APPS: PhoneAppDefinition[] = [
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/PhoneApp.vue')),
),
dockOrder: 0,
gridOrder: 0,
icon: markRaw(Phone),
iconClass: '',
iconImage: phoneIcon,
id: 'phone',
labelKey: 'Apps.phone.name',
route: '/apps/phone',
},
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/MapApp.vue')),
),
dockOrder: null,
gridOrder: 8,
gridOrder: 9,
icon: markRaw(MapPinned),
iconClass: '',
iconImage: mapIcon,
@@ -41,7 +56,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
defineAsyncComponent(() => import('@/views/apps/MailApp.vue')),
),
dockOrder: null,
gridOrder: 3,
gridOrder: 4,
icon: markRaw(Mail),
iconClass: '',
iconImage: mailIcon,
@@ -54,7 +69,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
defineAsyncComponent(() => import('@/views/apps/NotesApp.vue')),
),
dockOrder: null,
gridOrder: 4,
gridOrder: 5,
icon: markRaw(NotebookPen),
iconClass: '',
iconImage: notesIcon,
@@ -67,7 +82,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
defineAsyncComponent(() => import('@/views/apps/CalculatorApp.vue')),
),
dockOrder: 1,
gridOrder: 0,
gridOrder: 1,
icon: markRaw(Calculator),
iconClass: 'app-icon--calculator',
iconImage: calculatorIcon,
@@ -80,7 +95,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
defineAsyncComponent(() => import('@/views/apps/CameraApp.vue')),
),
dockOrder: 2,
gridOrder: 1,
gridOrder: 2,
icon: markRaw(Camera),
iconClass: 'app-icon--camera',
iconImage: cameraIcon,
@@ -93,7 +108,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
defineAsyncComponent(() => import('@/views/apps/ClockApp.vue')),
),
dockOrder: 3,
gridOrder: 2,
gridOrder: 3,
icon: markRaw(Clock3),
iconClass: 'app-icon--clock',
iconImage: clockIcon,
@@ -106,7 +121,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
defineAsyncComponent(() => import('@/views/apps/PhotosApp.vue')),
),
dockOrder: null,
gridOrder: 5,
gridOrder: 6,
icon: markRaw(Images),
iconClass: 'app-icon--photos',
iconImage: photosIcon,
@@ -118,8 +133,8 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/AppStoreApp.vue')),
),
dockOrder: 0,
gridOrder: 6,
dockOrder: null,
gridOrder: 7,
icon: markRaw(ShoppingBag),
iconClass: 'app-icon--store',
iconImage: appStoreIcon,
@@ -132,7 +147,7 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
defineAsyncComponent(() => import('@/views/apps/SettingsApp.vue')),
),
dockOrder: null,
gridOrder: 7,
gridOrder: 8,
icon: markRaw(Settings),
iconClass: 'app-icon--settings',
iconImage: settingsIcon,
+71
View File
@@ -0,0 +1,71 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useCallsStore } from '@/stores/calls'
import { nuiCall } from '@/utils/nui'
import { playPhoneTone } from '@/utils/tones'
vi.mock('@/utils/nui', () => ({
nuiCall: vi.fn(async () => ({ success: true, data: [] })),
}))
vi.mock('@/utils/tones', () => ({
playPhoneTone: vi.fn(() => vi.fn()),
}))
describe('calls store', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('window', {
matchMedia: vi.fn(() => ({ matches: false })),
setTimeout,
})
setActivePinia(createPinia())
})
afterEach(() => {
vi.clearAllMocks()
vi.clearAllTimers()
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('rings for incoming calls and stops when connected', () => {
const stop = vi.fn()
vi.mocked(playPhoneTone).mockReturnValueOnce(stop)
const calls = useCallsStore()
calls.applyCallState({
direction: 'incoming',
id: 'call-1',
otherNumber: '1234567890',
startedAt: 1,
state: 'ringing',
})
expect(playPhoneTone).toHaveBeenCalledWith('apex', 80, true)
calls.applyCallState({
direction: 'incoming',
id: 'call-1',
otherNumber: '1234567890',
startedAt: 1,
state: 'connected',
})
expect(stop).toHaveBeenCalledOnce()
})
it('clears terminal states and refreshes recents', async () => {
const calls = useCallsStore()
calls.applyCallState({
direction: 'outgoing',
id: 'call-2',
otherNumber: '1234567890',
startedAt: 1,
state: 'busy',
})
await vi.advanceTimersByTimeAsync(1600)
expect(calls.activeCall).toBeNull()
expect(nuiCall).toHaveBeenCalledWith('calls:recents')
})
})
+108
View File
@@ -0,0 +1,108 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneCall, PhoneContact, RecentCall } from '@/types/phone'
import { nuiCall, type NuiResponse } from '@/utils/nui'
import type { RingtoneId } from '@/utils/preferences'
import { playPhoneTone, type PhoneToneId } from '@/utils/tones'
const RINGTONE_TONES: Record<RingtoneId, PhoneToneId> = {
horizon: 'aurora',
pulse: 'signal',
skyline: 'apex',
}
export const useCallsStore = defineStore('calls', () => {
const phone = usePhoneStore()
const activeCall = ref<PhoneCall | null>(null)
const contacts = ref<PhoneContact[]>([])
const recents = ref<RecentCall[]>([])
let stopRingtone: (() => void) | null = null
async function bootstrap(): Promise<void> {
await Promise.all([loadContacts(), loadRecents()])
}
async function loadContacts(): Promise<void> {
const response = await nuiCall<PhoneContact[]>('contacts:list')
if (response.success && response.data) contacts.value = response.data
}
async function loadRecents(): Promise<void> {
const response = await nuiCall<RecentCall[]>('calls:recents')
if (response.success && response.data) recents.value = response.data
}
async function saveContact(contact: {
id?: string
name: string
phoneNumber: string
}): Promise<NuiResponse<PhoneContact>> {
const response = await nuiCall<PhoneContact>('contacts:save', contact)
if (response.success) await loadContacts()
return response
}
async function deleteContact(id: string): Promise<boolean> {
const response = await nuiCall('contacts:delete', { id })
if (response.success) await loadContacts()
return response.success
}
async function dial(phoneNumber: string): Promise<NuiResponse<PhoneCall>> {
const response = await nuiCall<PhoneCall>('calls:dial', { phoneNumber })
if (response.success && response.data) applyCallState(response.data)
return response
}
async function answer(): Promise<NuiResponse> {
if (!activeCall.value) return { success: false, error: 'call_not_found' }
return nuiCall('calls:answer', { id: activeCall.value.id })
}
async function decline(): Promise<boolean> {
if (!activeCall.value) return false
return (await nuiCall('calls:decline', { id: activeCall.value.id })).success
}
async function hangup(): Promise<boolean> {
if (!activeCall.value) return false
return (await nuiCall('calls:hangup', { id: activeCall.value.id })).success
}
function applyCallState(call: PhoneCall): void {
stopRingtone?.()
stopRingtone = null
activeCall.value = call
if (call.direction === 'incoming' && call.state === 'ringing') {
stopRingtone = playPhoneTone(
RINGTONE_TONES[phone.preferences.settings.ringtone],
phone.preferences.settings.ringtoneVolume,
true,
)
}
if (!['ringing', 'connected'].includes(call.state)) {
window.setTimeout(() => {
if (activeCall.value?.id === call.id) activeCall.value = null
void loadRecents()
}, 1600)
}
}
return {
activeCall,
answer,
applyCallState,
bootstrap,
contacts,
decline,
deleteContact,
dial,
hangup,
loadContacts,
loadRecents,
recents,
saveContact,
}
})
+70 -2
View File
@@ -66,6 +66,67 @@ const defaultLocales: LocaleTree = {
threeBody: 'A new world is waiting.',
},
},
phone: {
name: 'Phone',
recents: 'Recents',
contacts: 'Contacts',
keypad: 'Keypad',
noSim: 'No SIM',
noSimBody: 'Insert a SIM card in Settings to make calls.',
noRecents: 'No Recent Calls',
noContacts: 'No Contacts',
searchContacts: 'Search Contacts',
addContact: 'New Contact',
editContact: 'Edit Contact',
contactName: 'Name',
phoneNumber: 'Phone Number',
call: 'Call',
calling: 'calling...',
incoming: 'Incoming Call',
incomingDirection: 'Incoming',
outgoingDirection: 'Outgoing',
connected: 'Connected',
missed: 'Missed',
declined: 'Declined',
busy: 'Busy',
unavailable: 'Unavailable',
noAnswer: 'No Answer',
cancelled: 'Cancelled',
disconnected: 'Disconnected',
sim_removed: 'SIM Removed',
completed: 'Call Ended',
answer: 'Answer',
decline: 'Decline',
hangup: 'End',
addToContacts: 'Add to Contacts',
deleteContact: 'Delete Contact',
replaceTitle: 'Replace SIM?',
replaceBody: 'The current SIM will be returned to your inventory.',
choosePhone: 'Choose a Phone',
choosePhoneBody: 'Select the phone that should receive {number}.',
emptyPhone: 'No SIM inserted',
errors: {
invalid_contact: 'Enter a name and valid phone number.',
invalid_number: 'Enter a valid phone number.',
no_sim: 'This phone has no SIM card.',
airplane_mode: 'Turn off Airplane Mode to make calls.',
self_call: 'You cannot call your own number.',
busy: 'The line is busy.',
rate_limited: 'Too many calls. Try again in a minute.',
voice_unavailable: 'The configured phone voice service is unavailable.',
inventory_full: 'There is no room for the ejected SIM card.',
operation_in_progress:
'Another phone operation is already in progress.',
sim_request_expired:
'The SIM selection expired. Use the SIM card again.',
sim_not_owned: 'That SIM card is no longer in your inventory.',
phone_not_owned: 'That phone is no longer in your inventory.',
metadata_unsupported:
'The configured inventory cannot store unique SIM metadata.',
request_failed: 'The phone request failed.',
default: 'The phone request failed.',
},
},
calculator: { name: 'Calculator' },
map: {
name: 'Map',
@@ -336,6 +397,14 @@ const defaultLocales: LocaleTree = {
localStorageValue: 'On Device',
deviceInformation: 'Device Information',
imei: 'IMEI',
simCard: 'SIM Card',
simNumber: 'Phone Number',
simType: 'SIM Type',
registeredSim: 'Registered',
anonymousSim: 'Anonymous',
noSim: 'No SIM',
ejectSim: 'Eject SIM',
ejectSimBody: 'Return this SIM card to your inventory?',
linkedDevices: 'Linked Devices',
thisDevice: 'This Phone',
removeDevice: 'Remove Device',
@@ -346,8 +415,7 @@ const defaultLocales: LocaleTree = {
factoryResetBody:
'This removes the account and all local data from this phone. Cloud data and the IMEI remain.',
factoryResetProgress: 'Erasing iFruit Phone',
factoryResetWarning:
'Do not turn off this phone. This takes 60 seconds.',
factoryResetWarning: 'Do not turn off this phone. This takes 60 seconds.',
accountErrors: {
invalid_email: 'Choose a valid 332 character iFruit address.',
invalid_password: 'Password must be 664 characters.',
+1
View File
@@ -1,6 +1,7 @@
import type { Component } from 'vue'
export type PhoneAppId =
| 'phone'
| 'calculator'
| 'camera'
| 'clock'
+2
View File
@@ -1,4 +1,5 @@
import type { Note } from '@/utils/notes'
import type { PhoneSim } from '@/types/phone'
export type DeviceDataEntry<T = unknown> = {
payload: T
@@ -9,6 +10,7 @@ export type PhoneDevice = {
data: Record<string, DeviceDataEntry | undefined>
imei: string
name: string
sim: PhoneSim | null
}
export type PhoneNotificationDevicePayload = {
+51
View File
@@ -0,0 +1,51 @@
export type SimType = 'registered' | 'anonymous'
export type PhoneSim = {
id: string
number: string
registered: boolean
type: SimType
}
export type PhoneContact = {
created_at?: string
id: string
name: string
phone_number: string
updated_at?: string
}
export type CallDirection = 'incoming' | 'outgoing'
export type CallState =
| 'ringing'
| 'connected'
| 'completed'
| 'missed'
| 'declined'
| 'busy'
| 'unavailable'
| 'no_answer'
| 'cancelled'
| 'disconnected'
| 'sim_removed'
export type PhoneCall = {
answeredAt?: number
channel?: number
device?: { imei: string; name: string }
direction: CallDirection
id: string
otherNumber: string
startedAt: number
state: CallState
}
export type RecentCall = {
call_id: string
created_at: string
direction: CallDirection
duration_seconds: number
id: number
other_number: string
status: CallState
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { formatPhoneNumber, normalizePhoneNumber } from './phone'
describe('phone numbers', () => {
it('normalizes formatted ten digit values', () => {
expect(normalizePhoneNumber('(555) 123-4567')).toBe('5551234567')
expect(normalizePhoneNumber('123')).toBeNull()
})
it('formats keypad input incrementally', () => {
expect(formatPhoneNumber('5551234567')).toBe('555 123 4567')
expect(formatPhoneNumber('5551')).toBe('555 1')
})
})
+12
View File
@@ -0,0 +1,12 @@
export const PHONE_NUMBER_LENGTH = 10
export function normalizePhoneNumber(value: string): string | null {
const digits = value.replace(/\D/g, '')
return digits.length === PHONE_NUMBER_LENGTH ? digits : null
}
export function formatPhoneNumber(value: string): string {
const digits = value.replace(/\D/g, '').slice(0, PHONE_NUMBER_LENGTH)
const groups = [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6, 10)]
return groups.filter(Boolean).join(' ')
}
+1
View File
@@ -47,6 +47,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
PhoneAppId,
AppNotificationPreferences
> = {
phone: { enabled: true, sounds: true },
'app-store': { enabled: true, sounds: true },
calculator: { enabled: true, sounds: true },
camera: { enabled: true, sounds: true },
+434
View File
@@ -0,0 +1,434 @@
<script setup lang="ts">
import {
kBlock,
kButton,
kDialog,
kDialogButton,
kList,
kListInput,
kListItem,
kNavbar,
kPage,
kSearchbar,
kSegmented,
kSegmentedButton,
} from 'konsta/vue'
import {
Clock3,
ContactRound,
Delete,
Phone,
PhoneCall,
PhoneIncoming,
PhoneOff,
Plus,
} from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import { useCallsStore } from '@/stores/calls'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneContact, RecentCall } from '@/types/phone'
import { formatPhoneNumber, normalizePhoneNumber } from '@/utils/phone'
type PhoneTab = 'recents' | 'contacts' | 'keypad'
const phone = usePhoneStore()
const calls = useCallsStore()
const tab = ref<PhoneTab>('recents')
const query = ref('')
const keypad = ref('')
const editorOpened = ref(false)
const editingContact = ref<PhoneContact | null>(null)
const contactName = ref('')
const contactNumber = ref('')
const error = ref('')
const tabs = [
{ id: 'recents', icon: Clock3 },
{ id: 'contacts', icon: ContactRound },
{ id: 'keypad', icon: Phone },
] as const
const tabBarColors = {
strongHighlightBgIos: 'bg-[#e5e5ea] dark:bg-[#2c2c2e]',
}
const callButtonColors = {
fillBgIos: 'bg-[#34c759] active:bg-[#30b350]',
fillTextIos: 'text-white',
}
const endButtonColors = {
fillBgIos: 'bg-[#ff3b30] active:bg-[#e6352b]',
fillTextIos: 'text-white',
}
const visibleContacts = computed(() => {
const needle = query.value.trim().toLowerCase()
if (!needle) return calls.contacts
return calls.contacts.filter(
(contact) =>
contact.name.toLowerCase().includes(needle) ||
contact.phone_number.includes(needle.replace(/\D/g, '')),
)
})
const activeCallLabel = computed(() => {
const call = calls.activeCall
if (!call) return ''
if (call.state === 'ringing') {
return phone.t(
call.direction === 'incoming'
? 'Apps.phone.incoming'
: 'Apps.phone.calling',
)
}
const key = call.state === 'no_answer' ? 'noAnswer' : call.state
return phone.t(`Apps.phone.${key}`)
})
function eventValue(event: Event): string {
return (event.target as HTMLInputElement).value
}
function contactNameFor(number: string): string {
return (
calls.contacts.find((contact) => contact.phone_number === number)?.name ??
formatPhoneNumber(number)
)
}
function openContact(contact?: PhoneContact, number = ''): void {
editingContact.value = contact ?? null
contactName.value = contact?.name ?? ''
contactNumber.value = contact?.phone_number ?? number
error.value = ''
editorOpened.value = true
}
async function saveContact(): Promise<void> {
const number = normalizePhoneNumber(contactNumber.value)
if (!contactName.value.trim() || !number) {
error.value = phone.t('Apps.phone.errors.invalid_contact')
return
}
const response = await calls.saveContact({
id: editingContact.value?.id,
name: contactName.value.trim(),
phoneNumber: number,
})
if (!response.success) {
error.value = phone.t(`Apps.phone.errors.${response.error ?? 'default'}`)
return
}
editorOpened.value = false
}
async function startCall(number: string): Promise<void> {
error.value = ''
const response = await calls.dial(number)
if (!response.success) {
error.value = phone.t(`Apps.phone.errors.${response.error ?? 'default'}`)
}
}
async function answerCall(): Promise<void> {
error.value = ''
const response = await calls.answer()
if (!response.success) {
error.value = phone.t(`Apps.phone.errors.${response.error ?? 'default'}`)
}
}
async function deleteEditedContact(): Promise<void> {
if (!editingContact.value) return
if (await calls.deleteContact(editingContact.value.id)) {
editorOpened.value = false
}
}
function addDigit(digit: string): void {
if (keypad.value.length < 10) keypad.value += digit
}
function recentStatus(recent: RecentCall): string {
const key = recent.status === 'no_answer' ? 'noAnswer' : recent.status
return phone.t(`Apps.phone.${key}`)
}
function recentSubtitle(recent: RecentCall): string {
const status = recentStatus(recent)
const direction = phone.t(
`Apps.phone.${recent.direction === 'incoming' ? 'incomingDirection' : 'outgoingDirection'}`,
)
if (!recent.duration_seconds) return `${direction} · ${status}`
const minutes = Math.floor(recent.duration_seconds / 60)
const seconds = String(recent.duration_seconds % 60).padStart(2, '0')
return `${direction} · ${status} · ${minutes}:${seconds}`
}
onMounted(() => {
void calls.bootstrap()
})
</script>
<template>
<k-page
class="native-app phone-calls-app"
:class="{ 'phone-app--light': !phone.isDarkMode }"
>
<template v-if="calls.activeCall">
<k-navbar :title="phone.t('Apps.phone.name')" transparent />
<k-block
class="flex h-full flex-col items-center justify-center text-center"
>
<div
class="mb-3 flex h-20 w-20 items-center justify-center rounded-full bg-[#007aff] text-white"
>
<PhoneIncoming
v-if="calls.activeCall.direction === 'incoming'"
class="h-9 w-9"
/>
<PhoneCall v-else class="h-9 w-9" />
</div>
<h2 class="m-0 text-2xl font-semibold">
{{ contactNameFor(calls.activeCall.otherNumber) }}
</h2>
<p class="mt-2 text-[#8e8e93]">{{ activeCallLabel }}</p>
<p v-if="error" class="mt-2 text-sm text-[#ff3b30]">{{ error }}</p>
<div class="mt-10 flex gap-5">
<k-button
v-if="
calls.activeCall.state === 'ringing' &&
calls.activeCall.direction === 'incoming'
"
rounded
large
:colors="callButtonColors"
@click="answerCall"
>
<Phone class="mr-2 h-5 w-5" />{{ phone.t('Apps.phone.answer') }}
</k-button>
<k-button
v-if="
calls.activeCall.state === 'ringing' &&
calls.activeCall.direction === 'incoming'
"
rounded
large
:colors="endButtonColors"
@click="calls.decline()"
>
<PhoneOff class="mr-2 h-5 w-5" />{{ phone.t('Apps.phone.decline') }}
</k-button>
<k-button
v-else-if="
['ringing', 'connected'].includes(calls.activeCall.state)
"
rounded
large
:colors="endButtonColors"
@click="calls.hangup()"
>
<PhoneOff class="mr-2 h-5 w-5" />{{ phone.t('Apps.phone.hangup') }}
</k-button>
</div>
</k-block>
</template>
<template v-else>
<k-navbar :title="phone.t(`Apps.phone.${tab}`)" large transparent>
<template #right>
<k-button
v-if="tab === 'contacts'"
clear
rounded
@click="openContact()"
>
<Plus class="h-5 w-5" />
</k-button>
</template>
</k-navbar>
<div class="phone-call-content">
<k-block v-if="!phone.device?.sim" class="text-center">
<h2>{{ phone.t('Apps.phone.noSim') }}</h2>
<p class="text-[#8e8e93]">{{ phone.t('Apps.phone.noSimBody') }}</p>
</k-block>
<template v-else-if="tab === 'recents'">
<k-list v-if="calls.recents.length" strong inset>
<k-list-item
v-for="recent in calls.recents"
:key="recent.id"
:title="contactNameFor(recent.other_number)"
:subtitle="recentSubtitle(recent)"
link
@click="startCall(recent.other_number)"
>
<template #after>
<span class="flex flex-col items-end gap-1 text-xs">
{{ new Date(recent.created_at).toLocaleString(phone.lang) }}
<k-button
v-if="
!calls.contacts.some(
(contact) =>
contact.phone_number === recent.other_number,
)
"
clear
rounded
:aria-label="phone.t('Apps.phone.addToContacts')"
@click.stop="openContact(undefined, recent.other_number)"
>
<Plus class="h-5 w-5" />
</k-button>
</span>
</template>
</k-list-item>
</k-list>
<k-block v-else class="text-center text-[#8e8e93]">{{
phone.t('Apps.phone.noRecents')
}}</k-block>
</template>
<template v-else-if="tab === 'contacts'">
<k-searchbar
:value="query"
:placeholder="phone.t('Apps.phone.searchContacts')"
@input="query = eventValue($event)"
@clear="query = ''"
/>
<k-list v-if="visibleContacts.length" strong inset>
<k-list-item
v-for="contact in visibleContacts"
:key="contact.id"
:title="contact.name"
:subtitle="formatPhoneNumber(contact.phone_number)"
link
@click="openContact(contact)"
>
<template #after>
<k-button
clear
rounded
@click.stop="startCall(contact.phone_number)"
><Phone class="h-5 w-5"
/></k-button>
</template>
</k-list-item>
</k-list>
<k-block v-else class="text-center text-[#8e8e93]">{{
phone.t('Apps.phone.noContacts')
}}</k-block>
</template>
<template v-else>
<k-block class="text-center">
<div class="mb-5 min-h-10 text-2xl font-medium">
{{ formatPhoneNumber(keypad) }}
</div>
<div class="mx-auto grid max-w-[240px] grid-cols-3 gap-3">
<k-button
v-for="digit in [
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'*',
'0',
'#',
]"
:key="digit"
tonal
rounded
large
@click="addDigit(digit)"
>
{{ digit }}
</k-button>
</div>
<div class="mt-5 flex justify-center gap-4">
<k-button
:disabled="keypad.length !== 10"
rounded
large
:colors="callButtonColors"
@click="startCall(keypad)"
>
<Phone class="h-6 w-6" />
</k-button>
<k-button
tonal
rounded
large
:disabled="!keypad"
@click="keypad = keypad.slice(0, -1)"
>
<Delete class="h-6 w-6" />
</k-button>
</div>
</k-block>
</template>
<p v-if="error" class="px-5 text-center text-sm text-[#ff3b30]">
{{ error }}
</p>
</div>
<k-navbar component="nav" :aria-label="phone.t('Apps.phone.name')">
<template #subnavbar>
<k-segmented strong rounded :colors="tabBarColors">
<k-segmented-button
v-for="item in tabs"
:key="item.id"
large
:active="tab === item.id"
:class="tab === item.id ? 'text-[#007aff]' : 'text-[#8e8e93]'"
@click="tab = item.id"
>
<span
class="flex flex-col items-center gap-0.5 text-[10px] leading-none"
>
<component :is="item.icon" class="h-5 w-5" />
<span>{{ phone.t(`Apps.phone.${item.id}`) }}</span>
</span>
</k-segmented-button>
</k-segmented>
</template>
</k-navbar>
</template>
</k-page>
<k-dialog :opened="editorOpened" @backdropclick="editorOpened = false">
<template #title>{{
phone.t(
editingContact ? 'Apps.phone.editContact' : 'Apps.phone.addContact',
)
}}</template>
<k-list strong inset>
<k-list-input
:value="contactName"
:label="phone.t('Apps.phone.contactName')"
@input="contactName = eventValue($event)"
/>
<k-list-input
:value="contactNumber"
:label="phone.t('Apps.phone.phoneNumber')"
inputmode="numeric"
@input="contactNumber = eventValue($event)"
/>
</k-list>
<p v-if="error" class="text-sm text-[#ff3b30]">{{ error }}</p>
<template #buttons>
<k-dialog-button @click="editorOpened = false">{{
phone.t('Common.cancel')
}}</k-dialog-button>
<k-dialog-button v-if="editingContact" @click="deleteEditedContact">{{
phone.t('Common.delete')
}}</k-dialog-button>
<k-dialog-button strong @click="saveContact">{{
phone.t('Common.save')
}}</k-dialog-button>
</template>
</k-dialog>
</template>
+81 -23
View File
@@ -48,6 +48,8 @@ import { IFRUIT_AUTH_INPUT_COLORS } from '@/config/ifruit'
import { usePhoneStore } from '@/stores/phone'
import { useAccountStore } from '@/stores/account'
import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps'
import { nuiCall } from '@/utils/nui'
import { formatPhoneNumber } from '@/utils/phone'
import {
APPEARANCE_MODE_IDS,
NOTIFICATION_SOUND_IDS,
@@ -106,11 +108,11 @@ const removeDeviceImei = ref('')
const removeDevicePassword = ref('')
const removeDeviceOpened = ref(false)
const resetOpened = ref(false)
const simEjectOpened = ref(false)
const factoryResetting = ref(false)
const factoryResetProgress = ref(0)
const factoryResetDashOffset = computed(
() =>
FACTORY_RESET_CIRCUMFERENCE * (1 - factoryResetProgress.value / 100),
() => FACTORY_RESET_CIRCUMFERENCE * (1 - factoryResetProgress.value / 100),
)
const selectedFrameColor = computed(
() => PHONE_FRAME_COLORS[phone.preferences.settings.frame],
@@ -275,10 +277,8 @@ function openFramePicker(): void {
const targetTop = (targetRect.top - screenRect.top) / screenScale
const targetWidth = targetRect.width / screenScale
const targetHeight = targetRect.height / screenScale
const desiredLeft =
targetLeft + targetWidth / 2 - FRAME_PICKER_WIDTH / 2
const aboveTop =
targetTop - FRAME_PICKER_HEIGHT - FRAME_PICKER_GAP
const desiredLeft = targetLeft + targetWidth / 2 - FRAME_PICKER_WIDTH / 2
const aboveTop = targetTop - FRAME_PICKER_HEIGHT - FRAME_PICKER_GAP
const desiredTop =
aboveTop >= FRAME_PICKER_INSET
? aboveTop
@@ -408,6 +408,16 @@ async function confirmFactoryReset(): Promise<void> {
if (!success) accountToast.value = accountError()
}
async function confirmSimEject(): Promise<void> {
const response = await nuiCall('sim:eject')
simEjectOpened.value = false
if (!response.success) {
accountToast.value = phone.t(
`Apps.phone.errors.${response.error ?? 'default'}`,
)
}
}
onBeforeUnmount(() => {
if (factoryResetAnimationFrame !== undefined) {
cancelAnimationFrame(factoryResetAnimationFrame)
@@ -534,7 +544,9 @@ onBeforeUnmount(() => {
<k-link
v-if="activeView === 'account' && !account.email"
component="button"
@click="accountMode = accountMode === 'login' ? 'register' : 'login'"
@click="
accountMode = accountMode === 'login' ? 'register' : 'login'
"
>
{{
phone.t(
@@ -614,7 +626,12 @@ onBeforeUnmount(() => {
</k-list-input>
</k-list>
<k-block>
<k-button large rounded :disabled="accountSubmitting" @click="submitAccount">
<k-button
large
rounded
:disabled="accountSubmitting"
@click="submitAccount"
>
<k-preloader v-if="accountSubmitting" />
<template v-else>
{{
@@ -631,24 +648,28 @@ onBeforeUnmount(() => {
<template v-else>
<k-list strong inset>
<k-list-item
:title="account.email"
:subtitle="phone.t('Apps.settings.accountCloudDetail')"
>
<template #media>
<UserRound class="w-12 h-12 text-primary" />
</template>
</k-list-item>
<k-list-item
:title="account.email"
:subtitle="phone.t('Apps.settings.accountCloudDetail')"
>
<template #media>
<UserRound class="w-12 h-12 text-primary" />
</template>
</k-list-item>
</k-list>
<k-block-title>{{ phone.t('Apps.settings.linkedDevices') }}</k-block-title>
<k-block-title>{{
phone.t('Apps.settings.linkedDevices')
}}</k-block-title>
<k-list strong inset>
<k-list-item
v-for="device in account.devices"
:key="device.imei"
:title="device.device_name"
:subtitle="device.imei"
:after="device.current ? phone.t('Apps.settings.thisDevice') : undefined"
:after="
device.current ? phone.t('Apps.settings.thisDevice') : undefined
"
>
<template #media><Smartphone :size="22" /></template>
<template v-if="!device.current" #footer>
@@ -900,12 +921,39 @@ onBeforeUnmount(() => {
/>
</k-list>
<k-block-title>{{ phone.t('Apps.settings.deviceInformation') }}</k-block-title>
<k-block-title>{{
phone.t('Apps.settings.deviceInformation')
}}</k-block-title>
<k-list strong inset>
<k-list-item
:title="phone.t('Apps.settings.imei')"
:after="phone.device?.imei ?? '—'"
/>
<k-list-item
:title="phone.t('Apps.settings.simNumber')"
:after="
phone.device?.sim
? formatPhoneNumber(phone.device.sim.number)
: phone.t('Apps.settings.noSim')
"
/>
<k-list-item
v-if="phone.device?.sim"
:title="phone.t('Apps.settings.simType')"
:after="
phone.t(
phone.device.sim.type === 'registered'
? 'Apps.settings.registeredSim'
: 'Apps.settings.anonymousSim',
)
"
/>
<k-list-button
v-if="phone.device?.sim"
@click="simEjectOpened = true"
>
{{ phone.t('Apps.settings.ejectSim') }}
</k-list-button>
<k-list-button @click="resetOpened = true">
<RotateCcw :size="18" />
{{ phone.t('Apps.settings.factoryReset') }}
@@ -1102,10 +1150,20 @@ onBeforeUnmount(() => {
</template>
</k-dialog>
<k-dialog
:opened="resetOpened"
@backdropclick="resetOpened = false"
>
<k-dialog :opened="simEjectOpened" @backdropclick="simEjectOpened = false">
<template #title>{{ phone.t('Apps.settings.ejectSim') }}</template>
<p>{{ phone.t('Apps.settings.ejectSimBody') }}</p>
<template #buttons>
<k-dialog-button @click="simEjectOpened = false">{{
phone.t('Common.cancel')
}}</k-dialog-button>
<k-dialog-button strong @click="confirmSimEject">{{
phone.t('Apps.settings.ejectSim')
}}</k-dialog-button>
</template>
</k-dialog>
<k-dialog :opened="resetOpened" @backdropclick="resetOpened = false">
<template #title>{{ phone.t('Apps.settings.factoryReset') }}</template>
<p>{{ phone.t('Apps.settings.factoryResetBody') }}</p>
<template #buttons>
+8
View File
@@ -144,6 +144,14 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true })
return
}
if (
endpoint === 'sim:insert' &&
request.body.imei === '356938035643810' &&
!request.body.confirmed
) {
response.json({ success: false, error: 'confirmation_required' })
return
}
if (endpoint === 'notes:list') {
response.json({ success: true, data: mockNotes })
return
+15
View File
@@ -6,6 +6,21 @@ Config.Phone = {
DeviceName = "iFruit Phone",
}
Config.Sim = {
RegisteredItem = "sky_phone_sim_registered",
AnonymousItem = "sky_phone_sim_anonymous",
NumberLength = 10,
NumberPrefix = "",
NumberGroups = { 3, 3, 4 },
}
Config.Calls = {
VoiceProvider = "pma",
RingSeconds = 30,
ContactNameMaxLength = 80,
RecentPageSize = 100,
}
Config.Mail = {
Domain = "ifruit.com",
LocalPartMinLength = 3,
+32
View File
@@ -5,6 +5,12 @@ Locales["en"] = {
phone_stacked = "Phones cannot be stacked.",
invalid_imei = "This phone has invalid IMEI metadata.",
metadata_unsupported = "The configured inventory cannot store unique phone metadata.",
sim_slot_missing = "The used SIM card could not be identified.",
sim_stacked = "SIM cards cannot be stacked.",
invalid_sim = "This SIM card has invalid metadata.",
phone_required = "You need a phone in your inventory.",
operation_in_progress = "Another phone operation is already in progress.",
voice_unavailable = "The configured phone voice service is unavailable.",
default = "The phone could not be opened.",
},
Nui = {
@@ -30,6 +36,30 @@ Locales["en"] = {
},
},
Apps = {
phone = {
name = "Phone", recents = "Recents", contacts = "Contacts", keypad = "Keypad",
noSim = "No SIM", noSimBody = "Insert a SIM card in Settings to make calls.",
noRecents = "No Recent Calls", noContacts = "No Contacts", searchContacts = "Search Contacts",
addContact = "New Contact", editContact = "Edit Contact", contactName = "Name", phoneNumber = "Phone Number",
call = "Call", calling = "calling...", incoming = "Incoming Call", incomingDirection = "Incoming", outgoingDirection = "Outgoing", connected = "Connected",
missed = "Missed", declined = "Declined", busy = "Busy", unavailable = "Unavailable",
noAnswer = "No Answer", cancelled = "Cancelled", disconnected = "Disconnected", sim_removed = "SIM Removed", completed = "Call Ended",
answer = "Answer", decline = "Decline", hangup = "End", addToContacts = "Add to Contacts",
deleteContact = "Delete Contact", replaceTitle = "Replace SIM?",
replaceBody = "The current SIM will be returned to your inventory.", choosePhone = "Choose a Phone",
choosePhoneBody = "Select the phone that should receive {number}.", emptyPhone = "No SIM inserted",
errors = {
invalid_contact = "Enter a name and valid phone number.", invalid_number = "Enter a valid phone number.",
no_sim = "This phone has no SIM card.", airplane_mode = "Turn off Airplane Mode to make calls.",
self_call = "You cannot call your own number.", busy = "The line is busy.",
rate_limited = "Too many calls. Try again in a minute.", voice_unavailable = "The configured phone voice service is unavailable.",
inventory_full = "There is no room for the ejected SIM card.", request_failed = "The phone request failed.",
operation_in_progress = "Another phone operation is already in progress.", sim_request_expired = "The SIM selection expired. Use the SIM card again.",
sim_not_owned = "That SIM card is no longer in your inventory.", phone_not_owned = "That phone is no longer in your inventory.",
metadata_unsupported = "The configured inventory cannot store unique SIM metadata.",
default = "The phone request failed.",
},
},
calculator = { name = "Calculator" },
camera = {
name = "Camera", shutter = "Take photo", flip = "Flip camera", flash = "Toggle flash", controls = "Camera controls",
@@ -136,6 +166,8 @@ Locales["en"] = {
language = "Language", languageValue = "English", localStorage = "Local Storage", localStorageValue = "On Device",
back = "Settings", wallpaperPicker = "Built-in Wallpapers", deviceInformation = "Device Information",
imei = "IMEI", linkedDevices = "Linked Devices", thisDevice = "This Phone", removeDevice = "Remove Device",
simCard = "SIM Card", simNumber = "Phone Number", simType = "SIM Type", registeredSim = "Registered",
anonymousSim = "Anonymous", noSim = "No SIM", ejectSim = "Eject SIM", ejectSimBody = "Return this SIM card to your inventory?",
removeDeviceBody = "Enter your iFruit password to remove this device from the account.", signOut = "Sign Out",
factoryReset = "Erase All Content and Settings", factoryResetBody = "This removes the account and all local data from this phone. Cloud data and the IMEI remain.",
factoryResetProgress = "Erasing iFruit Phone", factoryResetWarning = "Do not turn off this phone. This takes 60 seconds.",
+5 -1
View File
@@ -10,8 +10,10 @@ escrow_ignore 'config/**'
shared_scripts {
'@sky_base/source/import.lua',
'@sky_jobs_base/source/import.lua',
'config/init.lua',
'source/shared/imei.lua',
'source/shared/sim_number.lua',
}
client_scripts {
@@ -24,6 +26,8 @@ server_scripts {
'config/config.lua',
'source/server/db_migrate.lua',
'source/server/phone.lua',
'source/server/sim.lua',
'source/server/calls.lua',
'source/server/notes.lua',
'source/server/mail.lua',
}
@@ -36,4 +40,4 @@ files {
ui_page 'source/html/index.html'
dependency 'sky_base'
dependencies { 'sky_base', 'sky_jobs_base' }
+82 -1
View File
@@ -1,6 +1,8 @@
local is_open = false
local notification_focus = false
local device_payload = nil
local sim_picker_open = false
local call_channel = 0
Sky.Debug("debug", "[sky_phone] Client script initialized.", { always = true })
@@ -31,6 +33,16 @@ local server_callbacks = {
"mail:restore",
"mail:delete-forever",
"mail:empty-trash",
"sim:insert",
"sim:eject",
"contacts:list",
"contacts:save",
"contacts:delete",
"calls:recents",
"calls:dial",
"calls:answer",
"calls:decline",
"calls:hangup",
}
local function get_locale()
@@ -68,11 +80,35 @@ local function close_phone()
end
is_open = false
SetNuiFocus(notification_focus, notification_focus)
SetNuiFocus(notification_focus or sim_picker_open, notification_focus or sim_picker_open)
SendNUIMessage({ type = "app:close" })
Sky.Cb.Trigger("sky_phone:device:close", {})
end
local function leave_call_voice()
if call_channel == 0 then
return
end
if Config.Calls.VoiceProvider == "pma" and GetResourceState("pma-voice") == "started" then
exports["pma-voice"]:setCallChannel(0)
end
call_channel = 0
end
local function join_call_voice(channel)
if Config.Calls.VoiceProvider ~= "pma" then
Sky.Debug("error", "[sky_phone] Unsupported voice provider '%s'.", tostring(Config.Calls.VoiceProvider))
return false
end
if GetResourceState("pma-voice") ~= "started" then
Sky.Debug("error", "[sky_phone] Configured pma-voice provider is not started.")
return false
end
call_channel = tonumber(channel) or 0
exports["pma-voice"]:setCallChannel(call_channel)
return true
end
if Config.Phone.DevelopmentCommand then
RegisterCommand(Config.Command, function()
if is_open then
@@ -102,6 +138,12 @@ RegisterNUICallback("notification:focus", function(data, cb)
cb({ success = true })
end)
RegisterNUICallback("sim:picker-close", function(_, cb)
sim_picker_open = false
SetNuiFocus(is_open or notification_focus, is_open or notification_focus)
cb({ success = true })
end)
RegisterNUICallback("map:getPlayerCoords", function(_, cb)
local coords = GetEntityCoords(PlayerPedId())
cb({
@@ -172,6 +214,43 @@ RegisterNetEvent("sky_phone:mail:new", function(data)
SendNUIMessage({ type = "mail:new", data = data })
end)
RegisterNetEvent("sky_phone:sim:picker", function(data)
sim_picker_open = true
SetNuiFocus(true, true)
SendNUIMessage({ type = "sim:picker", data = data })
end)
RegisterNetEvent("sky_phone:sim:picker-close", function()
sim_picker_open = false
SetNuiFocus(is_open or notification_focus, is_open or notification_focus)
SendNUIMessage({ type = "sim:picker-close" })
end)
RegisterNetEvent("sky_phone:contacts:changed", function()
SendNUIMessage({ type = "contacts:changed" })
end)
RegisterNetEvent("sky_phone:calls:changed", function()
SendNUIMessage({ type = "calls:changed" })
end)
RegisterNetEvent("sky_phone:call:incoming", function(data)
notification_focus = true
SetNuiFocus(true, true)
SendNUIMessage({ type = "call:incoming", data = data })
end)
RegisterNetEvent("sky_phone:call:state", function(data)
if data.state == "connected" and data.channel then
if not join_call_voice(data.channel) then
TriggerEvent("sky_phone:device:error", "voice_unavailable")
end
elseif data.state ~= "ringing" then
leave_call_voice()
end
SendNUIMessage({ type = "call:state", data = data })
end)
CreateThread(function()
if Config.Phone.DevelopmentCommand then
TriggerEvent("chat:addSuggestion", "/" .. Config.Command, get_locale().CommandDescription)
@@ -187,6 +266,8 @@ AddEventHandler("onResourceStop", function(resource_name)
SetNuiFocus(false, false)
end
leave_call_voice()
if Config.Phone.DevelopmentCommand then
TriggerEvent("chat:removeSuggestion", "/" .. Config.Command)
end
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-dB6ivhTC.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-CLd_xVxf.css">
<script type="module" crossorigin src="./assets/sky-index-D2k1S7R7.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-G8JTwQWl.css">
</head>
<body>
<div id="app"></div>
+521
View File
@@ -0,0 +1,521 @@
if not Sky.DB.AwaitMigrations("sky_phone") then
error("[sky_phone] Calling database migrations did not complete.")
end
SkyPhoneCalls = {}
local calls = {}
local active_by_source = {}
local active_by_sim = {}
local dial_locks = {}
local dialing_by_sim = {}
local next_voice_channel = 10000
local function uuid()
local rows = Sky.Query("SELECT UUID() AS `id`", {})
if not rows[1] or type(rows[1].id) ~= "string" then
error("[sky_phone] Database did not generate a call UUID.")
end
return rows[1].id
end
local function trim(value)
if type(value) ~= "string" then
return nil
end
return value:match("^%s*(.-)%s*$")
end
local function scope_for_device(device)
if device.account_id then
return tonumber(device.account_id), nil
end
return nil, device.imei
end
local function current_scope(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
local account_id, device_imei = scope_for_device(device)
return {
account_id = account_id,
device = device,
device_imei = device_imei,
session = session,
}
end
local function scope_condition(scope, alias)
local prefix = alias and (alias .. ".") or ""
if scope.account_id then
return prefix .. "`account_id` = ?", { scope.account_id }
end
return prefix .. "`device_imei` = ?", { scope.device_imei }
end
local function find_device_holder(imei)
for _, player_source in ipairs(Sky.FW.GetPlayers()) do
local source = tonumber(player_source) or player_source
if SkyPhone.FindDeviceSlots(source, imei)[1] then
return source
end
end
return nil
end
local function airplane_mode(imei)
local rows = Sky.Query([[
SELECT `payload` FROM `sky_phone_device_data`
WHERE `device_imei` = ? AND `namespace` = 'settings' LIMIT 1
]], { imei })
if not rows[1] then
return false
end
local payload = json.decode(rows[1].payload)
return payload and payload.settings and payload.settings.airplaneMode == true
end
local function add_call_entry(call_id, device, direction, status, other_number)
local account_id, device_imei = scope_for_device(device)
Sky.Query([[
INSERT INTO `sky_phone_call_entries`
(`call_id`, `account_id`, `device_imei`, `direction`, `status`, `other_number`)
VALUES (?, ?, ?, ?, ?, ?)
]], { call_id, account_id, device_imei, direction, status, other_number })
end
local function send_state(call, source, state, channel)
local outgoing = source == call.caller_source
TriggerClientEvent("sky_phone:call:state", source, {
id = call.id,
state = state,
direction = outgoing and "outgoing" or "incoming",
otherNumber = outgoing and call.callee_number or call.caller_number,
startedAt = call.started_at,
answeredAt = call.answered_at,
channel = channel,
})
end
local function notify_recents(device, source)
if device.account_id then
SkyPhone.NotifyAccount(device.account_id, "sky_phone:calls:changed", {})
elseif source then
TriggerClientEvent("sky_phone:calls:changed", source)
end
end
local function finish_call(call, status)
if not call or call.ended then
return
end
call.ended = true
local ended_at = os.time()
local duration = call.answered_at and math.max(0, ended_at - call.answered_at) or 0
local callee_status = status
if status == "no_answer" or status == "cancelled" then
callee_status = "missed"
end
Sky.DB.Transaction({
{
query = [[
UPDATE `sky_phone_calls`
SET `status` = ?, `ended_at` = CURRENT_TIMESTAMP, `duration_seconds` = ?
WHERE `id` = ?
]],
params = { status, duration, call.id },
},
{
query = "UPDATE `sky_phone_call_entries` SET `status` = ? WHERE `call_id` = ? AND `direction` = 'outgoing'",
params = { status, call.id },
},
{
query = "UPDATE `sky_phone_call_entries` SET `status` = ? WHERE `call_id` = ? AND `direction` = 'incoming'",
params = { callee_status, call.id },
},
})
active_by_source[call.caller_source] = nil
active_by_sim[call.caller_sim_id] = nil
if call.callee_source then
active_by_source[call.callee_source] = nil
end
if call.callee_sim_id then
active_by_sim[call.callee_sim_id] = nil
end
send_state(call, call.caller_source, status)
if call.callee_source then
send_state(call, call.callee_source, callee_status)
end
notify_recents(call.caller_device, call.caller_source)
if call.callee_device then
notify_recents(call.callee_device, call.callee_source)
end
calls[call.id] = nil
end
function SkyPhoneCalls.EndForSim(sim_id, reason)
local call_id = active_by_sim[sim_id]
if call_id then
finish_call(calls[call_id], reason or "ended")
end
end
function SkyPhoneCalls.LinkAccountData(account_id, imei)
local counts = Sky.Query([[
SELECT
(SELECT COUNT(*) FROM `sky_phone_contacts` WHERE `account_id` = ?) AS `contacts`,
(SELECT COUNT(*) FROM `sky_phone_call_entries` WHERE `account_id` = ?) AS `recents`
]], { account_id, account_id })
local has_cloud_data = counts[1] and ((tonumber(counts[1].contacts) or 0) > 0 or (tonumber(counts[1].recents) or 0) > 0)
if has_cloud_data then
return Sky.DB.Transaction({
{ query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL", params = { imei } },
{ query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL", params = { imei } },
})
end
return Sky.DB.Transaction({
{
query = "UPDATE `sky_phone_contacts` SET `account_id` = ?, `device_imei` = NULL WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { account_id, imei },
},
{
query = "UPDATE `sky_phone_call_entries` SET `account_id` = ?, `device_imei` = NULL WHERE `device_imei` = ? AND `account_id` IS NULL",
params = { account_id, imei },
},
})
end
function SkyPhoneCalls.CopyCloudToDevice(account_id, imei)
return Sky.DB.Transaction({
{ query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL", params = { imei } },
{ query = "DELETE FROM `sky_phone_call_entries` WHERE `device_imei` = ? AND `account_id` IS NULL", params = { imei } },
{
query = [[
INSERT INTO `sky_phone_contacts`
(`id`, `contact_id`, `device_imei`, `name`, `phone_number`, `created_at`, `updated_at`)
SELECT UUID(), `contact_id`, ?, `name`, `phone_number`, `created_at`, `updated_at`
FROM `sky_phone_contacts` WHERE `account_id` = ?
]],
params = { imei, account_id },
},
{
query = [[
INSERT INTO `sky_phone_call_entries`
(`call_id`, `device_imei`, `direction`, `status`, `other_number`, `created_at`)
SELECT `call_id`, ?, `direction`, `status`, `other_number`, `created_at`
FROM `sky_phone_call_entries` WHERE `account_id` = ?
]],
params = { imei, account_id },
},
})
end
Sky.Cb.Register("sky_phone:contacts:list", function(source)
local scope, error_response = current_scope(source)
if not scope then
return error_response
end
local condition, params = scope_condition(scope)
local rows = Sky.Query(([[
SELECT `contact_id` AS `id`, `name`, `phone_number`, `created_at`, `updated_at`
FROM `sky_phone_contacts` WHERE %s ORDER BY LOWER(`name`), `phone_number`
]]):format(condition), params)
return { success = true, data = rows }
end)
Sky.Cb.Register("sky_phone:contacts:save", function(source, data)
if not SkyPhone.AllowOperation(source, "contact_save", 30, 60) or type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
local scope, error_response = current_scope(source)
if not scope then
return error_response
end
local name = trim(data.name)
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not name or name == "" or #name > Config.Calls.ContactNameMaxLength or not number then
return { success = false, error = "invalid_contact" }
end
local condition, condition_params = scope_condition(scope)
local id = type(data.id) == "string" and data.id or uuid()
if data.id then
local owned_params = { id }
for _, value in ipairs(condition_params) do
owned_params[#owned_params + 1] = value
end
local owned = Sky.Query(("SELECT `id` FROM `sky_phone_contacts` WHERE `contact_id` = ? AND %s LIMIT 1"):format(condition), owned_params)
if not owned[1] then
return { success = false, error = "contact_not_found" }
end
local params = { name, number, id }
for _, value in ipairs(condition_params) do
params[#params + 1] = value
end
Sky.Query(([[
UPDATE `sky_phone_contacts` SET `name` = ?, `phone_number` = ?
WHERE `contact_id` = ? AND %s
]]):format(condition), params)
else
Sky.Query([[
INSERT INTO `sky_phone_contacts` (`id`, `contact_id`, `account_id`, `device_imei`, `name`, `phone_number`)
VALUES (?, ?, ?, ?, ?, ?)
]], { uuid(), id, scope.account_id, scope.device_imei, name, number })
end
if scope.account_id then
SkyPhone.NotifyAccount(scope.account_id, "sky_phone:contacts:changed", {})
end
return { success = true, data = { id = id, name = name, phone_number = number } }
end)
Sky.Cb.Register("sky_phone:contacts:delete", function(source, data)
if type(data) ~= "table" or type(data.id) ~= "string" then
return { success = false, error = "invalid_request" }
end
local scope, error_response = current_scope(source)
if not scope then
return error_response
end
local condition, values = scope_condition(scope)
local params = { data.id }
for _, value in ipairs(values) do
params[#params + 1] = value
end
Sky.Query(("DELETE FROM `sky_phone_contacts` WHERE `contact_id` = ? AND %s"):format(condition), params)
if scope.account_id then
SkyPhone.NotifyAccount(scope.account_id, "sky_phone:contacts:changed", {})
end
return { success = true }
end)
Sky.Cb.Register("sky_phone:calls:recents", function(source)
local scope, error_response = current_scope(source)
if not scope then
return error_response
end
local condition, params = scope_condition(scope, "e")
params[#params + 1] = Config.Calls.RecentPageSize
local rows = Sky.Query(([[
SELECT e.`id`, e.`call_id`, e.`direction`, e.`status`, e.`other_number`, e.`created_at`,
c.`duration_seconds`
FROM `sky_phone_call_entries` e
JOIN `sky_phone_calls` c ON c.`id` = e.`call_id`
WHERE %s ORDER BY e.`created_at` DESC, e.`id` DESC LIMIT ?
]]):format(condition), params)
return { success = true, data = rows }
end)
local function create_terminal_call(scope, number, target_sim, status)
local id = uuid()
Sky.Query([[
INSERT INTO `sky_phone_calls`
(`id`, `caller_sim_id`, `callee_sim_id`, `caller_number`, `callee_number`, `status`, `ended_at`)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
]], { id, scope.device.sim_id, target_sim and target_sim.id or nil, scope.device.phone_number, number, status })
add_call_entry(id, scope.device, "outgoing", status, number)
notify_recents(scope.device, nil)
return {
id = id,
state = status,
direction = "outgoing",
otherNumber = number,
startedAt = os.time(),
}
end
Sky.Cb.Register("sky_phone:calls:dial", function(source, data)
if not SkyPhone.AllowOperation(source, "call_dial", 15, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
if dial_locks[source] then
return { success = false, error = "busy" }
end
dial_locks[source] = true
local scope, error_response = current_scope(source)
if not scope then
dial_locks[source] = nil
return error_response
end
if not scope.device.sim_id then
dial_locks[source] = nil
return { success = false, error = "no_sim" }
end
if airplane_mode(scope.device.imei) then
dial_locks[source] = nil
return { success = false, error = "airplane_mode" }
end
local number = SkyPhoneSimNumber.Normalize(data.phoneNumber, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number then
dial_locks[source] = nil
return { success = false, error = "invalid_number" }
end
if number == scope.device.phone_number then
dial_locks[source] = nil
return { success = false, error = "self_call" }
end
if active_by_source[source] or active_by_sim[scope.device.sim_id] or dialing_by_sim[scope.device.sim_id] then
dial_locks[source] = nil
return { success = false, error = "busy" }
end
dialing_by_sim[scope.device.sim_id] = true
local targets = Sky.Query([[
SELECT s.`id`, s.`phone_number`, d.`imei`, d.`account_id`, d.`device_name`
FROM `sky_phone_sims` s LEFT JOIN `sky_phone_devices` d ON d.`sim_id` = s.`id`
WHERE s.`phone_number` = ? LIMIT 1
]], { number })
local target = targets[1]
if not target or not target.imei then
local terminal = create_terminal_call(scope, number, target, "unavailable")
dialing_by_sim[scope.device.sim_id] = nil
dial_locks[source] = nil
return { success = true, data = terminal }
end
local callee_source = find_device_holder(target.imei)
if not callee_source or airplane_mode(target.imei) then
local terminal = create_terminal_call(scope, number, target, "unavailable")
dialing_by_sim[scope.device.sim_id] = nil
dial_locks[source] = nil
return { success = true, data = terminal }
end
if active_by_source[callee_source] or active_by_sim[target.id] or dialing_by_sim[target.id] then
local terminal = create_terminal_call(scope, number, target, "busy")
dialing_by_sim[scope.device.sim_id] = nil
dial_locks[source] = nil
return { success = true, data = terminal }
end
dialing_by_sim[target.id] = true
local id = uuid()
local call = {
id = id,
caller_source = source,
caller_sim_id = scope.device.sim_id,
caller_number = scope.device.phone_number,
caller_device = scope.device,
callee_source = callee_source,
callee_sim_id = target.id,
callee_number = number,
callee_device = target,
started_at = os.time(),
}
Sky.Query([[
INSERT INTO `sky_phone_calls`
(`id`, `caller_sim_id`, `callee_sim_id`, `caller_number`, `callee_number`, `status`)
VALUES (?, ?, ?, ?, ?, 'ringing')
]], { id, call.caller_sim_id, call.callee_sim_id, call.caller_number, call.callee_number })
add_call_entry(id, scope.device, "outgoing", "ringing", number)
add_call_entry(id, target, "incoming", "ringing", call.caller_number)
calls[id] = call
active_by_source[source] = id
active_by_source[callee_source] = id
active_by_sim[call.caller_sim_id] = id
active_by_sim[call.callee_sim_id] = id
dialing_by_sim[call.caller_sim_id] = nil
dialing_by_sim[call.callee_sim_id] = nil
dial_locks[source] = nil
send_state(call, source, "ringing")
SkyPhone.OpenDeviceForCall(callee_source, target.imei)
TriggerClientEvent("sky_phone:call:incoming", callee_source, {
id = id,
state = "ringing",
direction = "incoming",
otherNumber = call.caller_number,
startedAt = call.started_at,
device = {
imei = target.imei,
name = target.device_name,
},
})
SetTimeout(Config.Calls.RingSeconds * 1000, function()
if calls[id] and not calls[id].answered_at then
finish_call(calls[id], "no_answer")
end
end)
return { success = true, data = { id = id, state = "ringing", direction = "outgoing", otherNumber = number, startedAt = call.started_at } }
end)
Sky.Cb.Register("sky_phone:calls:answer", function(source, data)
local call = type(data) == "table" and calls[data.id] or nil
if not call or call.callee_source ~= source or call.answered_at then
return { success = false, error = "call_not_found" }
end
if not SkyPhone.FindDeviceSlots(source, call.callee_device.imei)[1] then
finish_call(call, "unavailable")
return { success = false, error = "phone_not_owned" }
end
if Config.Calls.VoiceProvider ~= "pma" or GetResourceState("pma-voice") ~= "started" then
return { success = false, error = "voice_unavailable" }
end
call.answered_at = os.time()
call.channel = next_voice_channel
next_voice_channel = next_voice_channel + 1
Sky.Query("UPDATE `sky_phone_calls` SET `status` = 'connected', `answered_at` = CURRENT_TIMESTAMP WHERE `id` = ?", { call.id })
Sky.Query("UPDATE `sky_phone_call_entries` SET `status` = 'connected' WHERE `call_id` = ?", { call.id })
send_state(call, call.caller_source, "connected", call.channel)
send_state(call, call.callee_source, "connected", call.channel)
return { success = true }
end)
Sky.Cb.Register("sky_phone:calls:decline", function(source, data)
local call = type(data) == "table" and calls[data.id] or nil
if not call or call.callee_source ~= source or call.answered_at then
return { success = false, error = "call_not_found" }
end
finish_call(call, "declined")
return { success = true }
end)
Sky.Cb.Register("sky_phone:calls:hangup", function(source, data)
local call_id = active_by_source[source]
local call = call_id and calls[call_id] or nil
if not call or (type(data) == "table" and data.id and data.id ~= call.id) then
return { success = false, error = "call_not_found" }
end
finish_call(call, call.answered_at and "completed" or "cancelled")
return { success = true }
end)
CreateThread(function()
while true do
Wait(2000)
local invalid_calls = {}
for call_id, call in pairs(calls) do
if not SkyPhone.FindDeviceSlots(call.caller_source, call.caller_device.imei)[1]
or (call.callee_source and not SkyPhone.FindDeviceSlots(call.callee_source, call.callee_device.imei)[1])
then
invalid_calls[#invalid_calls + 1] = call_id
end
end
for _, call_id in ipairs(invalid_calls) do
finish_call(calls[call_id], "disconnected")
end
end
end)
AddEventHandler("playerDropped", function()
local call_id = active_by_source[source]
if call_id then
finish_call(calls[call_id], "disconnected")
end
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name ~= GetCurrentResourceName() then
return
end
local call_ids = {}
for call_id in pairs(calls) do
call_ids[#call_ids + 1] = call_id
end
for _, call_id in ipairs(call_ids) do
finish_call(calls[call_id], "disconnected")
end
end)
+104
View File
@@ -115,6 +115,27 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_sims",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "contact_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "phone_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "sim_type", type = "ENUM('registered', 'anonymous') NOT NULL" },
{ name = "owner_identifier", type = "VARCHAR(80) NULL" },
{ name = "owner_firstname", type = "VARCHAR(80) NULL" },
{ name = "owner_lastname", type = "VARCHAR(80) NULL" },
{ name = "owner_birthdate", type = "VARCHAR(32) NULL" },
{ name = "registered_at", type = "DATETIME NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_sim_number", columns = "(`phone_number`)" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_devices",
columns = {
@@ -125,6 +146,7 @@ local schema = {
collation = "ascii_bin",
},
{ name = "account_id", type = "BIGINT UNSIGNED NULL" },
{ name = "sim_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "device_name", type = "VARCHAR(64) NOT NULL DEFAULT 'iFruit Phone'" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{
@@ -133,6 +155,9 @@ local schema = {
},
},
primaryKey = "imei",
uniqueKeys = {
{ name = "uniq_sky_phone_devices_sim", columns = "(`sim_id`)" },
},
indexes = {
{ name = "idx_sky_phone_devices_account", columns = "(`account_id`, `updated_at`)" },
},
@@ -141,6 +166,10 @@ local schema = {
column = "account_id",
references = "`sky_phone_accounts` (`id`) ON DELETE SET NULL",
},
{
column = "sim_id",
references = "`sky_phone_sims` (`id`) ON DELETE SET NULL",
},
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
@@ -217,6 +246,81 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_contacts",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "account_id", type = "BIGINT UNSIGNED NULL" },
{ name = "device_imei", type = "CHAR(15) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "name", type = "VARCHAR(80) NOT NULL" },
{ name = "phone_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_contacts_account", columns = "(`account_id`, `name`)" },
{ name = "idx_sky_phone_contacts_device", columns = "(`device_imei`, `name`)" },
},
foreignKeys = {
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
{ column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_calls",
columns = {
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "caller_sim_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "callee_sim_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "caller_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "callee_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "status", type = "VARCHAR(24) NOT NULL" },
{ name = "started_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
{ name = "answered_at", type = "DATETIME NULL" },
{ name = "ended_at", type = "DATETIME NULL" },
{ name = "duration_seconds", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_calls_caller", columns = "(`caller_sim_id`, `started_at`)" },
{ name = "idx_sky_phone_calls_callee", columns = "(`callee_sim_id`, `started_at`)" },
},
foreignKeys = {
{ column = "caller_sim_id", references = "`sky_phone_sims` (`id`) ON DELETE CASCADE" },
{ column = "callee_sim_id", references = "`sky_phone_sims` (`id`) ON DELETE SET NULL" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_call_entries",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "call_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "account_id", type = "BIGINT UNSIGNED NULL" },
{ name = "device_imei", type = "CHAR(15) NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "direction", type = "ENUM('incoming', 'outgoing') NOT NULL" },
{ name = "status", type = "VARCHAR(24) NOT NULL" },
{ name = "other_number", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
indexes = {
{ name = "idx_sky_phone_call_entries_account", columns = "(`account_id`, `created_at`)" },
{ name = "idx_sky_phone_call_entries_device", columns = "(`device_imei`, `created_at`)" },
},
foreignKeys = {
{ column = "call_id", references = "`sky_phone_calls` (`id`) ON DELETE CASCADE" },
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
{ column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
}
Sky.DB.Migrate("sky_phone", schema)
Sky.DB.EnsureIndex("sky_phone_devices", "uniq_sky_phone_devices_sim", "(`sim_id`)", { unique = true })
Sky.Query("UPDATE `sky_phone_contacts` SET `contact_id` = `id` WHERE `contact_id` IS NULL", {})
Sky.DB.EnsureIndex("sky_phone_contacts", "uniq_sky_phone_contacts_account_contact", "(`account_id`, `contact_id`)", { unique = true })
Sky.DB.EnsureIndex("sky_phone_contacts", "uniq_sky_phone_contacts_device_contact", "(`device_imei`, `contact_id`)", { unique = true })
+43 -1
View File
@@ -216,9 +216,11 @@ end
local function load_device(imei)
local rows = Sky.Query([[
SELECT d.`imei`, d.`device_name`, d.`account_id`, d.`created_at`, d.`updated_at`, a.`email`
SELECT d.`imei`, d.`device_name`, d.`account_id`, d.`sim_id`, d.`created_at`, d.`updated_at`,
a.`email`, s.`phone_number`, s.`sim_type`, s.`registered_at`
FROM `sky_phone_devices` d
LEFT JOIN `sky_phone_accounts` a ON a.`id` = d.`account_id`
LEFT JOIN `sky_phone_sims` s ON s.`id` = d.`sim_id`
WHERE d.`imei` = ?
LIMIT 1
]], { imei })
@@ -270,6 +272,12 @@ local function bootstrap(source)
device = {
imei = device.imei,
name = device.device_name,
sim = device.sim_id and {
id = device.sim_id,
number = device.phone_number,
type = device.sim_type,
registered = device.registered_at ~= nil,
} or nil,
data = load_device_data(device.imei),
},
account = device.account_id and {
@@ -288,6 +296,11 @@ local function refresh_source(source)
end
end
SkyPhone.EnsureDevice = ensure_device
SkyPhone.FindDeviceSlots = find_device_slots
SkyPhone.LoadDevice = load_device
SkyPhone.RefreshSource = refresh_source
local function allow_auth_attempt(source)
local now = os.time()
local attempts = auth_attempts[source]
@@ -338,6 +351,9 @@ local function link_account(source, account)
return error_response
end
if not SkyPhoneCalls.LinkAccountData(account.id, session.imei) then
return { success = false, error = "request_failed" }
end
if not Sky.DB.Transaction({
{
query = "UPDATE `sky_phone_devices` SET `account_id` = ? WHERE `imei` = ?",
@@ -569,6 +585,21 @@ local function open_phone(source, used_item)
return true
end
function SkyPhone.OpenDeviceForCall(source, imei)
local matches = find_device_slots(source, imei)
if not matches[1] then
Sky.Debug("warn", "[sky_phone] Could not open ringing device %s for source %s.", tostring(imei), tostring(source))
return false
end
sessions[source] = {
imei = imei,
slot = matches[1].slot,
token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())),
}
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
return true
end
Sky.Debug(
"debug",
"[sky_phone] Registering usable item '%s' through inventory '%s'.",
@@ -674,6 +705,9 @@ for _, endpoint in ipairs({ "account:logout", "mail:logout" }) do
if not account then
return error_response
end
if not SkyPhoneCalls.CopyCloudToDevice(account.id, account.imei) then
return { success = false, error = "request_failed" }
end
Sky.Query("UPDATE `sky_phone_devices` SET `account_id` = NULL WHERE `imei` = ?", { account.imei })
refresh_source(source)
return { success = true }
@@ -738,6 +772,14 @@ Sky.Cb.Register("sky_phone:device:factory-reset", function(source)
query = "DELETE FROM `sky_phone_notes` 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 = "UPDATE `sky_phone_devices` SET `account_id` = NULL, `device_name` = ? WHERE `imei` = ?",
params = { Config.Phone.DeviceName, session.imei },
+315
View File
@@ -0,0 +1,315 @@
if not Sky.DB.AwaitMigrations("sky_phone") then
error("[sky_phone] SIM database migrations did not complete.")
end
SkyPhoneSim = {}
local pending_insertions = {}
local operation_locks = {}
local sim_types = {
[Config.Sim.RegisteredItem] = "registered",
[Config.Sim.AnonymousItem] = "anonymous",
}
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 uuid()
local rows = Sky.Query("SELECT UUID() AS `id`", {})
if not rows[1] or type(rows[1].id) ~= "string" then
error("[sky_phone] Database did not generate a SIM UUID.")
end
return rows[1].id
end
local function reserve_sim(sim_type)
local sim_id
local number = SkyPhoneSimNumber.Reserve(uuid, function(candidate)
sim_id = uuid()
local result = Sky.Query([[
INSERT IGNORE INTO `sky_phone_sims` (`id`, `phone_number`, `sim_type`)
VALUES (?, ?, ?)
]], { sim_id, candidate, sim_type })
return affected_rows(result) == 1
end, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not number then
error("[sky_phone] Could not reserve a unique SIM number after 20 attempts.")
end
return { id = sim_id, phone_number = number, sim_type = sim_type }
end
local function load_sim(sim_id)
local rows = Sky.Query("SELECT * FROM `sky_phone_sims` WHERE `id` = ? LIMIT 1", { sim_id })
return rows[1]
end
local function sim_metadata(sim)
local metadata = {
sim_id = sim.id,
phone_number = sim.phone_number,
formatted_number = SkyPhoneSimNumber.Format(sim.phone_number, Config.Sim.NumberGroups, Config.Sim.NumberLength, Config.Sim.NumberPrefix),
sim_type = sim.sim_type,
}
if sim.sim_type == "registered" and sim.owner_identifier then
metadata.firstname = sim.owner_firstname
metadata.lastname = sim.owner_lastname
metadata.birthdate = sim.owner_birthdate
end
return metadata
end
local function resolve_used_sim(source, used_item, item_name)
local slot_id = tonumber(used_item and (used_item.slot or used_item.id))
local slot = slot_id and Sky.FW.GetInventorySlot(source, slot_id) or nil
if slot and slot.name == item_name then
return slot
end
local slots = Sky.FW.GetInventorySlotsWithItem(source, item_name)
if #slots == 1 then
return slots[1]
end
Sky.Debug("warn", "[sky_phone] Could not resolve exact SIM slot for source %s.", tostring(source))
return nil
end
local function ensure_sim(source, slot, sim_type)
if (tonumber(slot.amount or slot.count) or 0) ~= 1 then
return nil, "sim_stacked"
end
local metadata = slot.metadata or {}
local sim = metadata.sim_id and load_sim(metadata.sim_id) or nil
if metadata.sim_id and (not sim or sim.sim_type ~= sim_type or sim.phone_number ~= metadata.phone_number) then
return nil, "invalid_sim"
end
if not sim then
sim = reserve_sim(sim_type)
if not Sky.FW.SetInventorySlotMetadata(source, slot.slot, sim_metadata(sim)) then
Sky.Query("DELETE FROM `sky_phone_sims` WHERE `id` = ?", { sim.id })
return nil, "metadata_unsupported"
end
end
return sim
end
local function list_phone_choices(source)
local choices = {}
for _, slot in ipairs(Sky.FW.GetInventorySlotsWithItem(source, Config.Phone.Item)) do
local imei = SkyPhone.EnsureDevice(source, slot)
if imei then
local device = SkyPhone.LoadDevice(imei)
choices[#choices + 1] = {
imei = imei,
name = device.device_name,
occupied = device.sim_id ~= nil,
number = device.phone_number,
}
end
end
return choices
end
local function rollback_phone_metadata(source, phone_slot, old_sim)
local metadata = phone_slot.metadata or {}
metadata.sim_id = old_sim and old_sim.id or nil
metadata.phone_number = old_sim and old_sim.phone_number or nil
metadata.formatted_number = old_sim and SkyPhoneSimNumber.Format(old_sim.phone_number, Config.Sim.NumberGroups, Config.Sim.NumberLength, Config.Sim.NumberPrefix) or nil
Sky.FW.SetInventorySlotMetadata(source, phone_slot.slot, metadata)
end
local function insert_sim(source, phone_imei, confirmed)
if operation_locks[source] then
return { success = false, error = "operation_in_progress" }
end
local pending = pending_insertions[source]
if not pending or GetGameTimer() - pending.created_at > 60000 then
pending_insertions[source] = nil
return { success = false, error = "sim_request_expired" }
end
local sim_slot = Sky.FW.GetInventorySlot(source, pending.slot)
if not sim_slot or sim_slot.name ~= pending.item_name or not sim_slot.metadata
or sim_slot.metadata.sim_id ~= pending.sim.id then
return { success = false, error = "sim_not_owned" }
end
local phone_matches = SkyPhone.FindDeviceSlots(source, phone_imei)
if not phone_matches[1] then
return { success = false, error = "phone_not_owned" }
end
local phone_slot = phone_matches[1]
local device = SkyPhone.LoadDevice(phone_imei)
local old_sim = device.sim_id and load_sim(device.sim_id) or nil
if old_sim and not confirmed then
return { success = false, error = "confirmation_required", data = { requiresConfirmation = true } }
end
operation_locks[source] = true
local phone_metadata = phone_slot.metadata or {}
phone_metadata.sim_id = pending.sim.id
phone_metadata.phone_number = pending.sim.phone_number
phone_metadata.formatted_number = SkyPhoneSimNumber.Format(pending.sim.phone_number, Config.Sim.NumberGroups, Config.Sim.NumberLength, Config.Sim.NumberPrefix)
if not Sky.FW.SetInventorySlotMetadata(source, phone_slot.slot, phone_metadata) then
operation_locks[source] = nil
return { success = false, error = "metadata_unsupported" }
end
if Sky.FW.RemoveItem(source, pending.item_name, 1, pending.slot) ~= 1 then
rollback_phone_metadata(source, phone_slot, old_sim)
operation_locks[source] = nil
return { success = false, error = "sim_not_owned" }
end
local old_item_name = old_sim and (old_sim.sim_type == "registered" and Config.Sim.RegisteredItem or Config.Sim.AnonymousItem) or nil
if old_sim and not Sky.FW.AddItem(source, old_item_name, 1, pending.slot, sim_metadata(old_sim)) then
Sky.FW.AddItem(source, pending.item_name, 1, pending.slot, sim_metadata(pending.sim))
rollback_phone_metadata(source, phone_slot, old_sim)
operation_locks[source] = nil
return { success = false, error = "inventory_full" }
end
local transaction = {
{
query = "UPDATE `sky_phone_devices` SET `sim_id` = ? WHERE `imei` = ?",
params = { pending.sim.id, phone_imei },
},
}
if pending.sim.sim_type == "registered" and not pending.sim.owner_identifier then
transaction[#transaction + 1] = {
query = [[
UPDATE `sky_phone_sims`
SET `owner_identifier` = ?, `owner_firstname` = ?, `owner_lastname` = ?,
`owner_birthdate` = ?, `registered_at` = CURRENT_TIMESTAMP
WHERE `id` = ? AND `owner_identifier` IS NULL
]],
params = {
Sky_Jobs.PlayerCache.GetIdentifier(source),
Sky.FW.GetFirstname(source),
Sky.FW.GetLastname(source),
Sky.FW.GetBirthdate(source),
pending.sim.id,
},
}
end
if not Sky.DB.Transaction(transaction) then
if old_sim then
Sky.FW.RemoveItem(source, old_sim.sim_type == "registered" and Config.Sim.RegisteredItem or Config.Sim.AnonymousItem, 1, pending.slot)
end
Sky.FW.AddItem(source, pending.item_name, 1, pending.slot, sim_metadata(pending.sim))
rollback_phone_metadata(source, phone_slot, old_sim)
operation_locks[source] = nil
return { success = false, error = "request_failed" }
end
pending_insertions[source] = nil
operation_locks[source] = nil
if old_sim then
SkyPhoneCalls.EndForSim(old_sim.id, "sim_removed")
end
SkyPhone.RefreshDevice(phone_imei)
TriggerClientEvent("sky_phone:sim:picker-close", source)
return { success = true }
end
local function use_sim(source, used_item)
local item_name = used_item and used_item.name
local sim_type = item_name and sim_types[item_name]
if not sim_type then
Sky.Debug("warn", "[sky_phone] Usable SIM callback received an invalid item for source %s.", tostring(source))
return false
end
if operation_locks[source] then
TriggerClientEvent("sky_phone:device:error", source, "operation_in_progress")
return false
end
operation_locks[source] = true
local slot = resolve_used_sim(source, used_item, item_name)
if not slot then
operation_locks[source] = nil
TriggerClientEvent("sky_phone:device:error", source, "sim_slot_missing")
return false
end
local sim, error_code = ensure_sim(source, slot, sim_type)
if not sim then
operation_locks[source] = nil
TriggerClientEvent("sky_phone:device:error", source, error_code)
return false
end
local choices = list_phone_choices(source)
if #choices == 0 then
operation_locks[source] = nil
TriggerClientEvent("sky_phone:device:error", source, "phone_required")
return false
end
pending_insertions[source] = {
created_at = GetGameTimer(),
item_name = item_name,
sim = sim,
slot = slot.slot,
}
operation_locks[source] = nil
if #choices == 1 and not choices[1].occupied then
return insert_sim(source, choices[1].imei, false).success
end
TriggerClientEvent("sky_phone:sim:picker", source, {
choices = choices,
number = sim.phone_number,
})
return true
end
Sky.FW.RegisterUsableItem(Config.Sim.RegisteredItem, use_sim, true)
Sky.FW.RegisterUsableItem(Config.Sim.AnonymousItem, use_sim, true)
Sky.Cb.Register("sky_phone:sim:insert", function(source, data)
if type(data) ~= "table" or not SkyPhoneImei.IsValid(data.imei) then
return { success = false, error = "invalid_request" }
end
return insert_sim(source, data.imei, data.confirmed == true)
end)
Sky.Cb.Register("sky_phone:sim:eject", function(source)
if operation_locks[source] then
return { success = false, error = "operation_in_progress" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return error_response
end
local device = SkyPhone.LoadDevice(session.imei)
local sim = device and device.sim_id and load_sim(device.sim_id) or nil
if not sim then
return { success = false, error = "no_sim" }
end
local phone_slot = Sky.FW.GetInventorySlot(source, session.slot)
local item_name = sim.sim_type == "registered" and Config.Sim.RegisteredItem or Config.Sim.AnonymousItem
local metadata = sim_metadata(sim)
if not Sky.FW.CanCarryItem(source, item_name, 1, metadata) then
return { success = false, error = "inventory_full" }
end
operation_locks[source] = true
rollback_phone_metadata(source, phone_slot, nil)
if not Sky.FW.AddItem(source, item_name, 1, nil, metadata) then
rollback_phone_metadata(source, phone_slot, sim)
operation_locks[source] = nil
return { success = false, error = "inventory_full" }
end
if affected_rows(Sky.Query("UPDATE `sky_phone_devices` SET `sim_id` = NULL WHERE `imei` = ? AND `sim_id` = ?", {
session.imei,
sim.id,
})) ~= 1 then
Sky.FW.RemoveItem(source, item_name, 1, nil, metadata)
rollback_phone_metadata(source, phone_slot, sim)
operation_locks[source] = nil
return { success = false, error = "request_failed" }
end
operation_locks[source] = nil
SkyPhoneCalls.EndForSim(sim.id, "sim_removed")
SkyPhone.RefreshDevice(session.imei)
return { success = true }
end)
AddEventHandler("playerDropped", function()
pending_insertions[source] = nil
operation_locks[source] = nil
end)
+59
View File
@@ -0,0 +1,59 @@
SkyPhoneSimNumber = {}
function SkyPhoneSimNumber.Normalize(value, length, prefix)
if type(value) ~= "string" and type(value) ~= "number" then
return nil
end
local number = tostring(value):gsub("%D", "")
if #number ~= length or number:sub(1, #prefix) ~= prefix then
return nil
end
return number
end
function SkyPhoneSimNumber.FromEntropy(entropy, length, prefix)
if type(entropy) ~= "string" or entropy == "" then
return nil
end
local source = entropy:gsub("%D", "")
if source == "" then
return nil
end
local number = prefix
local index = 1
while #number < length do
number = number .. source:sub(index, index)
index = index + 1
if index > #source and #number < length then
index = 1
end
end
return number
end
function SkyPhoneSimNumber.Format(value, groups, length, prefix)
local number = SkyPhoneSimNumber.Normalize(value, length, prefix)
if not number then
return tostring(value or "")
end
local formatted = {}
local offset = 1
for _, group_length in ipairs(groups) do
formatted[#formatted + 1] = number:sub(offset, offset + group_length - 1)
offset = offset + group_length
end
if offset <= #number then
formatted[#formatted + 1] = number:sub(offset)
end
return table.concat(formatted, " ")
end
function SkyPhoneSimNumber.Reserve(entropy, accept, length, prefix)
for _ = 1, 20 do
local candidate = SkyPhoneSimNumber.FromEntropy(entropy(), length, prefix)
if candidate and accept(candidate) then
return candidate
end
end
return nil
end
+19
View File
@@ -0,0 +1,19 @@
dofile("sky_phone/source/shared/sim_number.lua")
local number = SkyPhoneSimNumber.FromEntropy("550e8400-e29b-41d4-a716-446655440000", 10, "")
assert(number == "5508400294", "SIM number must be derived deterministically from entropy")
assert(SkyPhoneSimNumber.Normalize("550 840 0294", 10, "") == number, "formatted numbers must normalize")
assert(SkyPhoneSimNumber.Normalize("123", 10, "") == nil, "short numbers must fail")
assert(SkyPhoneSimNumber.Format(number, { 3, 3, 4 }, 10, "") == "550 840 0294", "groups must format")
local attempts = 0
local reserved = SkyPhoneSimNumber.Reserve(function()
attempts = attempts + 1
return attempts == 1 and "550e8400-e29b-41d4-a716-446655440000" or "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
end, function()
return attempts == 2
end, 10, "")
assert(attempts == 2, "SIM reservation must retry collisions")
assert(reserved == SkyPhoneSimNumber.FromEntropy("6ba7b810-9dad-11d1-80b4-00c04fd430c8", 10, ""), "reservation must return accepted number")
print("SIM number tests passed")