@kissmyappss/react-native 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,598 @@
1
+ // The KissMyApps app client, with no React Native in it: the app's storage, device and
2
+ // network come in through `Environment`, so the same code runs in tests and in Node.
3
+ // The Android and iOS SDKs follow the same rules; see integrations/analytics-mobile.
4
+
5
+ export const SDK_VERSION = '1.0.0'
6
+ export const DEFAULT_ENDPOINT = 'https://events.kissmyapps.dev'
7
+
8
+ export type Properties = Record<string, unknown>
9
+
10
+ /** AsyncStorage, MMKV or anything with the same two methods. */
11
+ export interface Storage {
12
+ getItem(key: string): Promise<string | null> | string | null
13
+ setItem(key: string, value: string): Promise<void> | void
14
+ }
15
+
16
+ export interface DeviceInfo {
17
+ platform: 'ios' | 'android'
18
+ os_name: string
19
+ os_version: string
20
+ device_type: 'mobile' | 'tablet'
21
+ device_model?: string
22
+ screen_width: number
23
+ screen_height: number
24
+ timezone: string
25
+ language: string
26
+ }
27
+
28
+ export interface ClientOptions {
29
+ /** The project's client key, kma_pk_live_… or kma_pk_test_…. */
30
+ key: string
31
+ endpoint?: string
32
+ /** The app's version, such as 2.4.0, and build number, such as 120. */
33
+ appVersion?: string
34
+ build?: string
35
+ /** A session ends after this long in the background or without events. Default 30. */
36
+ sessionTimeoutMinutes?: number
37
+ /** How often queued events are sent while the app is open. Default 10. */
38
+ flushIntervalSeconds?: number
39
+ /** The most events kept while offline; the oldest go first. Default 1000. */
40
+ maxQueue?: number
41
+ /** Record installs, updates, opens and backgrounding. Default true. */
42
+ trackLifecycle?: boolean
43
+ debug?: boolean
44
+ }
45
+
46
+ export interface Environment {
47
+ storage: Storage | null
48
+ device(): DeviceInfo
49
+ fetch: (url: string, init: RequestInit) => Promise<Pick<Response, 'ok' | 'status' | 'text'>>
50
+ sdk: string
51
+ now?: () => number
52
+ log?: (message: string) => void
53
+ }
54
+
55
+ interface Session {
56
+ id: string
57
+ started: number
58
+ last: number
59
+ firstScreen?: string
60
+ }
61
+
62
+ interface State {
63
+ anonymousId: string
64
+ userId: string | null
65
+ traits: Properties | null
66
+ session: Session | null
67
+ optedOut: boolean
68
+ version?: string
69
+ build?: string
70
+ }
71
+
72
+ export interface QueuedEvent {
73
+ name: string
74
+ insert_id: string
75
+ time: string
76
+ screen?: string
77
+ properties: Properties
78
+ user_id: string | null
79
+ anonymous_id: string
80
+ session_id: string
81
+ session_started_at: string
82
+ }
83
+
84
+ export const EVENT_NAME = /^[A-Za-z][A-Za-z0-9_.:-]{0,79}$/
85
+ const KEY = /^kma_pk_(live|test)_[A-Za-z0-9]{20,64}$/
86
+ const BATCH_SIZE = 50
87
+ const FLUSH_AT = 30
88
+ const HEARTBEAT_MS = 60_000
89
+ const MAX_PROPERTIES_BYTES = 32_000
90
+
91
+ export function uuid(): string {
92
+ // Hermes has no crypto.randomUUID; a polyfill may add it.
93
+ const random = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
94
+ if (random?.randomUUID) return random.randomUUID()
95
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
96
+ const r = (Math.random() * 16) | 0
97
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
98
+ })
99
+ }
100
+
101
+ export class KmaClient {
102
+ readonly options: ClientOptions & { endpoint: string }
103
+ private readonly env: Environment
104
+ private readonly storageKey: string
105
+ private state: State
106
+ private queue: QueuedEvent[] = []
107
+ private screenName: string | null = null
108
+ private foregroundAt = 0
109
+ private loaded = false
110
+ private readonly waiting: (() => void)[] = []
111
+ private flushTimer: ReturnType<typeof setTimeout> | null = null
112
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null
113
+ private saveTimer: ReturnType<typeof setTimeout> | null = null
114
+ private inflight: Promise<void> | null = null
115
+ private failures = 0
116
+ private lastSent = 0
117
+ private identityPending = false
118
+ private inForeground = false
119
+ private firstLaunch = false
120
+ private stopped = false
121
+ /** Settles once saved state is read and the launch is recorded. */
122
+ readonly ready: Promise<void>
123
+
124
+ constructor(options: ClientOptions, env: Environment) {
125
+ if (!KEY.test(options.key ?? '')) {
126
+ throw new Error('KissMyApps: init needs a client key that starts with kma_pk_')
127
+ }
128
+ this.options = {
129
+ ...options,
130
+ endpoint: (options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, ''),
131
+ }
132
+ this.env = env
133
+ this.storageKey = `kma:${options.key.slice(-8)}`
134
+ this.state = {
135
+ anonymousId: uuid(),
136
+ userId: null,
137
+ traits: null,
138
+ session: null,
139
+ optedOut: false,
140
+ }
141
+ this.ready = this.load()
142
+ }
143
+
144
+ // Public API ---------------------------------------------------------------------------
145
+
146
+ /** Records something a person did. */
147
+ track(name: string, properties: Properties = {}): void {
148
+ this.whenLoaded(() => {
149
+ if (this.stopped || this.state.optedOut) return
150
+ if (!EVENT_NAME.test(name)) {
151
+ this.warn(
152
+ `"${name}" is not a valid event name: use letters, numbers and _ . : - (up to 80).`,
153
+ )
154
+ return
155
+ }
156
+ this.enqueue(name, properties, this.ensureSession())
157
+ })
158
+ }
159
+
160
+ /** Records a screen view; later events carry the screen's name. */
161
+ screen(name: string, properties: Properties = {}): void {
162
+ const screen = String(name ?? '')
163
+ .trim()
164
+ .slice(0, 300)
165
+ if (!screen) return this.warn('screen needs a name.')
166
+ this.whenLoaded(() => {
167
+ if (this.stopped || this.state.optedOut) return
168
+ const previous = this.screenName
169
+ this.screenName = screen
170
+ const session = this.ensureSession()
171
+ if (!session.firstScreen) {
172
+ session.firstScreen = screen
173
+ this.save()
174
+ }
175
+ this.enqueue(
176
+ 'screen_viewed',
177
+ { name: screen, ...(previous ? { previous } : {}), ...properties },
178
+ session,
179
+ )
180
+ })
181
+ }
182
+
183
+ /** Ties this install to your own user ID, with optional traits such as email, name or plan. */
184
+ identify(userId: string | number, traits?: Properties): void {
185
+ const id = String(userId ?? '').trim()
186
+ if (!id || id.length > 200) return this.warn('identify needs a user ID of 1 to 200 characters.')
187
+ this.whenLoaded(() => {
188
+ this.state.userId = id
189
+ if (traits) this.state.traits = { ...(this.state.traits ?? {}), ...this.clean(traits) }
190
+ this.identityPending = true
191
+ this.save()
192
+ this.scheduleFlush(0)
193
+ })
194
+ }
195
+
196
+ /** Updates traits for the current person without changing who they are. */
197
+ setTraits(traits: Properties): void {
198
+ this.whenLoaded(() => {
199
+ this.state.traits = { ...(this.state.traits ?? {}), ...this.clean(traits) }
200
+ this.identityPending = true
201
+ this.save()
202
+ this.scheduleFlush(0)
203
+ })
204
+ }
205
+
206
+ /** Forgets the user, for example on sign out: what follows is a new person and session. */
207
+ reset(): void {
208
+ this.whenLoaded(() => {
209
+ void this.flush()
210
+ this.state.userId = null
211
+ this.state.traits = null
212
+ this.identityPending = false
213
+ this.state.anonymousId = uuid()
214
+ this.state.session = null
215
+ this.save()
216
+ if (this.inForeground) this.ensureSession()
217
+ })
218
+ }
219
+
220
+ /** Stops recording and drops anything not sent yet, until optIn. Remembered across launches. */
221
+ optOut(): void {
222
+ this.whenLoaded(() => {
223
+ this.state.optedOut = true
224
+ this.queue = []
225
+ this.save()
226
+ })
227
+ }
228
+
229
+ optIn(): void {
230
+ this.whenLoaded(() => {
231
+ this.state.optedOut = false
232
+ this.save()
233
+ })
234
+ }
235
+
236
+ getAnonymousId(): string {
237
+ return this.state.anonymousId
238
+ }
239
+
240
+ getUserId(): string | null {
241
+ return this.state.userId
242
+ }
243
+
244
+ getSessionId(): string | null {
245
+ return this.state.session?.id ?? null
246
+ }
247
+
248
+ /** Sends everything queued now. */
249
+ flush(): Promise<void> {
250
+ if (!this.loaded) return this.ready.then(() => this.flush())
251
+ if (this.inflight) return this.inflight
252
+ this.inflight = this.sendQueued().finally(() => {
253
+ this.inflight = null
254
+ })
255
+ return this.inflight
256
+ }
257
+
258
+ /** Stops timers; the client records nothing more. */
259
+ shutdown(): void {
260
+ this.stopped = true
261
+ this.inForeground = false
262
+ if (this.flushTimer) clearTimeout(this.flushTimer)
263
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
264
+ if (this.saveTimer) {
265
+ clearTimeout(this.saveTimer)
266
+ void this.writeNow()
267
+ }
268
+ }
269
+
270
+ // App lifecycle, called by the platform layer ------------------------------------------
271
+
272
+ /** The app came to the front. The first call is the launch. */
273
+ foreground(): void {
274
+ this.whenLoaded(() => {
275
+ if (this.stopped || this.inForeground) return
276
+ const launch = this.foregroundAt === 0
277
+ this.inForeground = true
278
+ this.foregroundAt = this.now()
279
+ if (this.state.optedOut) return
280
+ const session = this.ensureSession()
281
+ if (this.options.trackLifecycle !== false) {
282
+ if (launch) this.recordInstallOrUpdate(session)
283
+ this.enqueue('app_opened', { from_background: !launch }, session)
284
+ }
285
+ this.heartbeatTimer ??= setInterval(() => this.heartbeat(), HEARTBEAT_MS)
286
+ })
287
+ }
288
+
289
+ /** The app went to the background: record it and send what is queued while it can. */
290
+ background(): void {
291
+ this.whenLoaded(() => {
292
+ if (this.stopped || !this.inForeground) return
293
+ this.inForeground = false
294
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
295
+ this.heartbeatTimer = null
296
+ if (this.state.optedOut) return
297
+ const session = this.state.session
298
+ if (session && this.options.trackLifecycle !== false) {
299
+ const seconds = Math.round((this.now() - this.foregroundAt) / 1000)
300
+ this.enqueue('app_backgrounded', { seconds_in_foreground: Math.max(0, seconds) }, session)
301
+ }
302
+ if (session) session.last = this.now()
303
+ void this.writeNow()
304
+ void this.flush()
305
+ })
306
+ }
307
+
308
+ // State --------------------------------------------------------------------------------
309
+
310
+ private async load(): Promise<void> {
311
+ const storage = this.env.storage
312
+ if (!storage) this.warn('No storage was given, so each launch counts as a new install.')
313
+ let saved: Partial<State> | null = null
314
+ let queue: QueuedEvent[] = []
315
+ try {
316
+ const raw = await storage?.getItem(this.storageKey)
317
+ saved = raw ? (JSON.parse(raw) as Partial<State>) : null
318
+ const rawQueue = await storage?.getItem(`${this.storageKey}:queue`)
319
+ queue = rawQueue ? (JSON.parse(rawQueue) as QueuedEvent[]) : []
320
+ } catch {
321
+ saved = null
322
+ }
323
+ if (saved?.anonymousId) {
324
+ this.state = {
325
+ anonymousId: saved.anonymousId,
326
+ userId: saved.userId ?? null,
327
+ traits: saved.traits ?? null,
328
+ session: saved.session ?? null,
329
+ optedOut: saved.optedOut === true,
330
+ version: saved.version,
331
+ build: saved.build,
332
+ }
333
+ }
334
+ // Nothing saved means a first launch, or the app's data was cleared.
335
+ this.firstLaunch = !saved?.anonymousId
336
+ this.queue = Array.isArray(queue) ? queue : []
337
+ this.identityPending = Boolean(this.state.traits)
338
+ this.loaded = true
339
+ // What an earlier launch could not send goes now.
340
+ if (this.queue.length) this.scheduleFlush(0)
341
+ for (const run of this.waiting.splice(0)) run()
342
+ }
343
+
344
+ private whenLoaded(run: () => void): void {
345
+ if (this.loaded) run()
346
+ else this.waiting.push(run)
347
+ }
348
+
349
+ private recordInstallOrUpdate(session: Session): void {
350
+ const version = this.options.appVersion
351
+ const build = this.options.build
352
+ if (this.firstLaunch) {
353
+ this.enqueue('app_installed', this.clean({ version, build }), session)
354
+ } else if (
355
+ (version && this.state.version && version !== this.state.version) ||
356
+ (build && this.state.build && build !== this.state.build)
357
+ ) {
358
+ this.enqueue(
359
+ 'app_updated',
360
+ this.clean({
361
+ version,
362
+ build,
363
+ previous_version: this.state.version,
364
+ previous_build: this.state.build,
365
+ }),
366
+ session,
367
+ )
368
+ }
369
+ this.firstLaunch = false
370
+ this.state.version = version ?? this.state.version
371
+ this.state.build = build ?? this.state.build
372
+ this.save()
373
+ }
374
+
375
+ private save(): void {
376
+ if (this.saveTimer) return
377
+ this.saveTimer = setTimeout(() => {
378
+ this.saveTimer = null
379
+ void this.writeNow()
380
+ }, 250)
381
+ }
382
+
383
+ private async writeNow(): Promise<void> {
384
+ if (this.saveTimer) {
385
+ clearTimeout(this.saveTimer)
386
+ this.saveTimer = null
387
+ }
388
+ try {
389
+ await this.env.storage?.setItem(this.storageKey, JSON.stringify(this.state))
390
+ await this.env.storage?.setItem(`${this.storageKey}:queue`, JSON.stringify(this.queue))
391
+ } catch {
392
+ // Storage full or failing: the client keeps working in memory.
393
+ }
394
+ }
395
+
396
+ // Sessions and events -------------------------------------------------------------------
397
+
398
+ private now(): number {
399
+ return this.env.now?.() ?? Date.now()
400
+ }
401
+
402
+ private ensureSession(): Session {
403
+ const now = this.now()
404
+ const timeout = (this.options.sessionTimeoutMinutes ?? 30) * 60_000
405
+ let session = this.state.session
406
+ if (!session || now - session.last > timeout) {
407
+ session = { id: uuid(), started: now, last: now, firstScreen: this.screenName ?? undefined }
408
+ this.state.session = session
409
+ this.enqueue('session_started', {}, session)
410
+ } else {
411
+ session.last = now
412
+ }
413
+ return session
414
+ }
415
+
416
+ private enqueue(name: string, properties: Properties, session: Session): void {
417
+ this.queue.push({
418
+ name,
419
+ insert_id: uuid(),
420
+ time: new Date(this.now()).toISOString(),
421
+ screen: this.screenName ?? undefined,
422
+ properties: this.clean(properties),
423
+ user_id: this.state.userId,
424
+ anonymous_id: this.state.anonymousId,
425
+ session_id: session.id,
426
+ session_started_at: new Date(session.started).toISOString(),
427
+ })
428
+ const max = this.options.maxQueue ?? 1000
429
+ if (this.queue.length > max) this.queue.splice(0, this.queue.length - max)
430
+ this.save()
431
+ this.scheduleFlush(
432
+ this.queue.length >= FLUSH_AT ? 0 : (this.options.flushIntervalSeconds ?? 10) * 1000,
433
+ )
434
+ }
435
+
436
+ private clean(properties: Properties): Properties {
437
+ try {
438
+ const json = JSON.stringify(properties ?? {})
439
+ if (json.length > MAX_PROPERTIES_BYTES) {
440
+ this.warn('Event properties are over 32 KB and were dropped.')
441
+ return {}
442
+ }
443
+ const parsed = JSON.parse(json)
444
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
445
+ } catch {
446
+ return {}
447
+ }
448
+ }
449
+
450
+ // Sending ------------------------------------------------------------------------------
451
+
452
+ private context(session: Session | null): Properties {
453
+ const device = this.env.device()
454
+ return {
455
+ platform: device.platform,
456
+ sdk: this.env.sdk,
457
+ timezone: device.timezone,
458
+ language: device.language,
459
+ device_type: device.device_type,
460
+ device_model: device.device_model,
461
+ os_name: device.os_name,
462
+ os_version: device.os_version,
463
+ app_version: this.options.appVersion,
464
+ screen_width: Math.round(device.screen_width),
465
+ screen_height: Math.round(device.screen_height),
466
+ page_path: session?.firstScreen,
467
+ }
468
+ }
469
+
470
+ /** One request's body: queued events share a person and session; with none, a heartbeat. */
471
+ body(events: QueuedEvent[]): Properties {
472
+ const head = events[0]
473
+ const session = this.state.session
474
+ const sessionId = head?.session_id ?? session?.id
475
+ const startedAt =
476
+ head?.session_started_at ?? (session ? new Date(session.started).toISOString() : undefined)
477
+ const live = session?.id === sessionId ? session : null
478
+ const current = !head || head.anonymous_id === this.state.anonymousId
479
+ const userId = current ? this.state.userId : events[events.length - 1].user_id
480
+ const body: Properties = {
481
+ anonymous_id: head?.anonymous_id ?? this.state.anonymousId,
482
+ session: sessionId ? { id: sessionId, started_at: startedAt } : undefined,
483
+ sent_at: new Date(this.now()).toISOString(),
484
+ context: this.context(live),
485
+ events: events.map(({ name, insert_id, time, screen, properties, user_id }) => ({
486
+ name,
487
+ insert_id,
488
+ time,
489
+ ...(screen ? { screen } : {}),
490
+ properties,
491
+ user_id,
492
+ })),
493
+ }
494
+ if (userId) body.user_id = userId
495
+ if (current && this.state.traits) body.traits = this.state.traits
496
+ return JSON.parse(JSON.stringify(body))
497
+ }
498
+
499
+ private nextBatch(): QueuedEvent[] {
500
+ const head = this.queue[0]
501
+ if (!head) return []
502
+ const batch: QueuedEvent[] = []
503
+ for (const event of this.queue) {
504
+ if (
505
+ event.session_id !== head.session_id ||
506
+ event.anonymous_id !== head.anonymous_id ||
507
+ batch.length >= BATCH_SIZE
508
+ )
509
+ break
510
+ batch.push(event)
511
+ }
512
+ return batch
513
+ }
514
+
515
+ private async post(body: Properties) {
516
+ return this.env.fetch(`${this.options.endpoint}/v1/events`, {
517
+ method: 'POST',
518
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${this.options.key}` },
519
+ body: JSON.stringify(body),
520
+ })
521
+ }
522
+
523
+ private async sendQueued(): Promise<void> {
524
+ while (!this.stopped && !this.state.optedOut) {
525
+ const batch = this.nextBatch()
526
+ if (!batch.length && !(this.identityPending && this.state.session)) return
527
+ const traitsSent = this.state.traits
528
+ const carriesIdentity = !batch.length || batch[0].anonymous_id === this.state.anonymousId
529
+ let response: Awaited<ReturnType<Environment['fetch']>>
530
+ try {
531
+ response = await this.post(this.body(batch))
532
+ } catch {
533
+ this.retryLater()
534
+ return
535
+ }
536
+ const refused =
537
+ response.status >= 400 &&
538
+ response.status < 500 &&
539
+ response.status !== 408 &&
540
+ response.status !== 429
541
+ if (!response.ok && !refused) {
542
+ this.retryLater()
543
+ return
544
+ }
545
+ if (refused)
546
+ this.warn(
547
+ `Events were refused (${response.status}): ${await response.text().catch(() => '')}`,
548
+ )
549
+ const sent = new Set(batch.map((event) => event.insert_id))
550
+ this.queue = this.queue.filter((event) => !sent.has(event.insert_id))
551
+ if (response.ok && carriesIdentity && this.state.traits === traitsSent) {
552
+ this.state.traits = null
553
+ this.identityPending = false
554
+ }
555
+ this.failures = 0
556
+ this.lastSent = this.now()
557
+ this.save()
558
+ }
559
+ }
560
+
561
+ private retryLater(): void {
562
+ this.failures += 1
563
+ this.scheduleFlush(Math.min(60_000, 1000 * 2 ** Math.min(this.failures, 6)), true)
564
+ }
565
+
566
+ private scheduleFlush(delay: number, replace = false): void {
567
+ if (this.stopped) return
568
+ if (this.flushTimer) {
569
+ if (delay > 0 && !replace) return
570
+ clearTimeout(this.flushTimer)
571
+ }
572
+ this.flushTimer = setTimeout(() => {
573
+ this.flushTimer = null
574
+ void this.flush()
575
+ }, delay)
576
+ }
577
+
578
+ /** Keeps an open app counted as online. */
579
+ private heartbeat(): void {
580
+ const session = this.state.session
581
+ if (this.stopped || this.state.optedOut || !this.inForeground || !session) return
582
+ session.last = this.now()
583
+ if (this.queue.length || this.identityPending) {
584
+ void this.flush()
585
+ return
586
+ }
587
+ if (this.now() - this.lastSent < HEARTBEAT_MS - 1000) return
588
+ this.post(this.body([]))
589
+ .then(() => {
590
+ this.lastSent = this.now()
591
+ })
592
+ .catch(() => undefined)
593
+ }
594
+
595
+ private warn(message: string): void {
596
+ if (this.options.debug) (this.env.log ?? console.warn)(`KissMyApps: ${message}`)
597
+ }
598
+ }
package/src/device.ts ADDED
@@ -0,0 +1,59 @@
1
+ // What React Native itself can tell about the device, with no native module to link.
2
+
3
+ import { Dimensions, NativeModules, Platform } from 'react-native'
4
+ import type { DeviceInfo } from './client'
5
+
6
+ function timezone(): string {
7
+ try {
8
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
9
+ } catch {
10
+ return 'UTC'
11
+ }
12
+ }
13
+
14
+ function language(): string {
15
+ const fromSettings: unknown =
16
+ Platform.OS === 'ios'
17
+ ? (NativeModules.SettingsManager?.settings?.AppleLocale ??
18
+ NativeModules.SettingsManager?.settings?.AppleLanguages?.[0])
19
+ : NativeModules.I18nManager?.localeIdentifier
20
+ if (typeof fromSettings === 'string' && fromSettings)
21
+ return fromSettings.replace(/_/g, '-').split('@')[0]
22
+ try {
23
+ return Intl.DateTimeFormat().resolvedOptions().locale || 'en'
24
+ } catch {
25
+ return 'en'
26
+ }
27
+ }
28
+
29
+ export function deviceInfo(): DeviceInfo {
30
+ const screen = Dimensions.get('screen')
31
+ const shortSide = Math.min(screen.width, screen.height)
32
+ const constants = (Platform.constants ?? {}) as Record<string, unknown>
33
+ if (Platform.OS === 'ios') {
34
+ const pad = Platform.isPad === true || constants.interfaceIdiom === 'pad'
35
+ return {
36
+ platform: 'ios',
37
+ os_name: pad ? 'iPadOS' : 'iOS',
38
+ os_version: String(constants.osVersion ?? Platform.Version),
39
+ device_type: pad ? 'tablet' : 'mobile',
40
+ device_model: pad ? 'iPad' : 'iPhone',
41
+ screen_width: screen.width,
42
+ screen_height: screen.height,
43
+ timezone: timezone(),
44
+ language: language(),
45
+ }
46
+ }
47
+ const model = typeof constants.Model === 'string' ? constants.Model : undefined
48
+ return {
49
+ platform: 'android',
50
+ os_name: 'Android',
51
+ os_version: String(constants.Release ?? Platform.Version),
52
+ device_type: shortSide >= 600 ? 'tablet' : 'mobile',
53
+ device_model: model,
54
+ screen_width: screen.width,
55
+ screen_height: screen.height,
56
+ timezone: timezone(),
57
+ language: language(),
58
+ }
59
+ }