mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
ADD - calls app and sim cards
This commit is contained in:
@@ -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
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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 3–32 character iFruit address.',
|
||||
invalid_password: 'Password must be 6–64 characters.',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export type PhoneAppId =
|
||||
| 'phone'
|
||||
| 'calculator'
|
||||
| 'camera'
|
||||
| 'clock'
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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(' ')
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user