package expo.modules.appmetrics.storage import android.content.Context import android.util.Log import expo.modules.appmetrics.AppMetadata import expo.modules.appmetrics.AppMetricsPreferences import expo.modules.appmetrics.SQLITE_MAX_BIND_VARIABLES import expo.modules.appmetrics.TAG import expo.modules.appmetrics.GlobalAttributes import expo.modules.appmetrics.utils.JsonAny import expo.modules.appmetrics.utils.TimeUtils import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json import java.util.UUID import java.util.concurrent.CopyOnWriteArrayList class SessionManager( context: Context, database: MetricsDatabase? = null ) { private val context: Context = context private val database: MetricsDatabase = database ?: MetricsDatabase.getDatabase(context) fun interface MetricsInsertListener { suspend fun onMetricsInserted(metricIds: List) } fun interface LogsInsertListener { suspend fun onLogsInserted(logIds: List) } private val metricsInsertListeners = CopyOnWriteArrayList() private val logsInsertListeners = CopyOnWriteArrayList() fun addMetricsInsertListener(listener: MetricsInsertListener) { metricsInsertListeners.add(listener) } fun removeMetricsInsertListener(listener: MetricsInsertListener) { metricsInsertListeners.remove(listener) } fun addLogsInsertListener(listener: LogsInsertListener) { logsInsertListeners.add(listener) } fun removeLogsInsertListener(listener: LogsInsertListener) { logsInsertListeners.remove(listener) } fun createSessionId(): String = UUID.randomUUID().toString() suspend fun startSessionWithIdAt( sessionId: String, timestamp: String, metadata: AppMetadata? = null, environment: String? = null ) { val resolvedEnvironment = environment ?: AppMetricsPreferences.getEnvironment(context) val session = Session( id = sessionId, startTimestamp = timestamp, isActive = true, environment = resolvedEnvironment, appName = metadata?.appName, appIdentifier = metadata?.appIdentifier, appVersion = metadata?.appVersion, appBuildNumber = metadata?.appBuildNumber, appUpdateId = metadata?.appUpdatesInfo?.updateId, appUpdateRuntimeVersion = metadata?.appUpdatesInfo?.runtimeVersion, appUpdateRequestHeaders = metadata?.appUpdatesInfo?.requestHeaders?.let { Json.encodeToString(MapSerializer(String.serializer(), String.serializer()), it) }, appEasBuildId = metadata?.appEasBuildId, deviceOs = metadata?.deviceOs, deviceOsVersion = metadata?.deviceOsVersion, deviceModel = metadata?.deviceModel, deviceName = metadata?.deviceName, expoSdkVersion = metadata?.expoSdkVersion, reactNativeVersion = metadata?.reactNativeVersion, clientVersion = metadata?.clientVersion, languageTag = metadata?.languageTag ) database.sessionDao().insert(session) } suspend fun stopSession(sessionId: String) { database.sessionDao().stopSessionAt( sessionId, endTimestamp = TimeUtils.getCurrentTimestampInISOFormat() ) } suspend fun addMetrics( metrics: List, sessionId: String ) { val metricsWithSession = metrics.map { metric -> metric.copy( sessionId = sessionId, params = mergeGlobalAttributesIntoJsonString(metric.params) ) } database.metricDao().insertAll(metricsWithSession) val metricIds = metricsWithSession.map { it.metricId } metricsInsertListeners.forEach { listener -> try { listener.onMetricsInserted(metricIds) } catch (e: Exception) { Log.e(TAG, "MetricsInsertListener failed", e) } } } /** * Inactive sessions with their metrics, logs, and crash report attached, most * recent first. The crash report joins via the `sessionId` foreign key, so only * attributed reports map back; orphans (null sessionId) are excluded. */ suspend fun getInactiveSessions(): List = database.sessionDao().getInactive() /** * Persists a crash report. A non-null `sessionId` attributes the report to an * existing session (the FK requires the row to exist) and replaces any previous * report for it — only one crash per session is meaningful. A null `sessionId` * stores an orphan — see `CrashReportEntity`. */ suspend fun setCrashReport( sessionId: String?, payload: String, createdAt: String = TimeUtils.getCurrentTimestampInISOFormat() ) { database.crashReportDao().upsert( CrashReportEntity(sessionId = sessionId, payload = payload, createdAt = createdAt) ) } suspend fun getCrashReport(sessionId: String): String? = database.crashReportDao().getBySessionId(sessionId)?.payload /** * Every stored crash report, newest first — both reports attributed to a * session and orphans (startup crashes before the session existed, or native * crashes that couldn't be attributed). Returns the entities so callers can * read each report's `sessionId` (null for an orphan) alongside its payload. */ suspend fun getAllCrashReports(): List = database.crashReportDao().getAll() suspend fun getSessionById(id: String): SessionWithMetrics? = database.sessionDao().getSessionWithMetricsBySessionId(id) suspend fun getSessionRow(id: String): Session? = database.sessionDao().getById(id) /** * The most recent main session other than `currentSessionId`, or `null` when * none exists. Used to attribute crashes that carry no session id of their own * (native crashes, lost crash files) to the session that most likely produced * them — the previous process's. Android has no session `type` column yet, so * every stored session is treated as main. */ suspend fun getPreviousMainSessionId(currentSessionId: String?): String? = database.sessionDao().getPreviousMainSessionId(currentSessionId) suspend fun getMetricsForSession(sessionId: String): List = database.metricDao().getMetricsForSession(sessionId) suspend fun getLogsForSession(sessionId: String): List = database.logDao().getLogsForSession(sessionId) suspend fun clearAllData() { database.sessionDao().deleteAll() // Deleting the sessions cascades to their attributed reports, but orphan // reports (null sessionId) don't cascade, so wipe the table explicitly too. database.crashReportDao().deleteAll() } suspend fun deactivateAllSessionsBefore(timestamp: String) { database.sessionDao().deactivateAllSessionsBefore(timestamp) } /** * Prunes inactive sessions whose `startTimestamp` is older than the retention * window. Their metrics and attributed crash reports are removed via the * foreign-key cascade; orphan reports (null sessionId) have no session to * cascade from and are aged out separately by `createdAt`. */ suspend fun cleanupOldSessions() { val cutoffTimestamp = TimeUtils.getTimestampInISOFormatFromPast(MetricsConstants.SECONDS_TO_REMOVE_OLD_METRICS) database.crashReportDao().deleteOrphansOlderThan(cutoffTimestamp) database.sessionDao().deleteSessionsOlderThan(cutoffTimestamp) } suspend fun addLogs( logs: List, sessionId: String ) { val logsWithSession = logs.map { log -> log.copy( sessionId = sessionId, attributes = mergeGlobalAttributesIntoJsonString(log.attributes) ) } database.logDao().insertAll(logsWithSession) val logIds = logsWithSession.map { it.logId } logsInsertListeners.forEach { listener -> try { listener.onLogsInserted(logIds) } catch (e: Exception) { Log.e(TAG, "LogsInsertListener failed", e) } } } suspend fun cleanupOldLogs() { val cutoffTimestamp = TimeUtils.getTimestampInISOFormatFromPast(MetricsConstants.SECONDS_TO_REMOVE_OLD_METRICS) database.logDao().deleteLogsOlderThan(cutoffTimestamp) } suspend fun updateEnvironmentForActiveSessions(environment: String) { database.sessionDao().updateEnvironmentForActiveSessions(environment) } suspend fun getSessionsWithMetrics(metricIds: List): List { val metricsBySessionId = metricIds .distinct() .chunked(SQLITE_MAX_BIND_VARIABLES) .flatMap { database.metricDao().getByIds(it) } .sortedBy { it.timestamp } .groupBy { it.sessionId } val sessionsById = getSessionsByIds(metricsBySessionId.keys).associateBy { it.id } return metricsBySessionId.mapNotNull { (sessionId, metrics) -> sessionsById[sessionId]?.let { session -> SessionWithMetrics(session = session, metrics = metrics) } } } suspend fun getSessionsWithLogs(logIds: List): List { val logsBySessionId = logIds .distinct() .chunked(SQLITE_MAX_BIND_VARIABLES) .flatMap { database.logDao().getByIds(it) } .sortedBy { it.timestamp } .groupBy { it.sessionId } val sessionsById = getSessionsByIds(logsBySessionId.keys).associateBy { it.id } return logsBySessionId.mapNotNull { (sessionId, logs) -> sessionsById[sessionId]?.let { session -> SessionWithLogs(session = session, logs = logs) } } } private suspend fun getSessionsByIds(sessionIds: Collection): List = sessionIds.chunked(SQLITE_MAX_BIND_VARIABLES).flatMap { database.sessionDao().getByIds(it) } /** * Decodes a JSON-encoded `params` / `attributes` column, folds the current * `GlobalAttributes` snapshot into it, and re-encodes. Returns the original * string when there's nothing to merge in — empty globals, or a non-null * input that couldn't be parsed as a JSON object (we preserve whatever the * caller wrote rather than silently replacing it). */ private fun mergeGlobalAttributesIntoJsonString(json: String?): String? { val existing = json?.let { JsonAny.decodeJsonStringToMap(it) } if (json != null && existing == null) { return json } val merged = GlobalAttributes.mergeWith(existing) ?: return json return JsonAny.encodeMapToJsonString(merged) } }