diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/SessionModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/SessionModule.kt index baa4ef105..1dc8cc988 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/SessionModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/SessionModule.kt @@ -18,6 +18,8 @@ import com.onesignal.session.internal.outcomes.impl.OutcomeEventsPreferences import com.onesignal.session.internal.outcomes.impl.OutcomeEventsRepository import com.onesignal.session.internal.session.ISessionService import com.onesignal.session.internal.session.SessionModelStore +import com.onesignal.session.internal.session.backend.ISessionsBackendService +import com.onesignal.session.internal.session.backend.impl.SessionsBackendService import com.onesignal.session.internal.session.impl.SessionListener import com.onesignal.session.internal.session.impl.SessionService @@ -43,5 +45,6 @@ internal class SessionModule : IModule { .provides() builder.register().provides() builder.register().provides() + builder.register().provides() } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt new file mode 100644 index 000000000..9ca5869a4 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/ISessionsBackendService.kt @@ -0,0 +1,48 @@ +package com.onesignal.session.internal.session.backend + +import com.onesignal.common.exceptions.BackendException + +/** + * The backend service for the public sessions API. + * + * If there is a non-successful response from the backend, a [BackendException] will be thrown with response data. + */ +internal interface ISessionsBackendService { + /** + * Create a session. + * + * @return The backend session ID, or null if the success response did not include one. + */ + suspend fun createSession( + appId: String, + request: CreateSessionRequest, + ): String? + + /** + * Update an existing session with its cumulative duration, and end it when [UpdateSessionRequest.endTime] is set. + */ + suspend fun updateSession( + appId: String, + sessionId: String, + request: UpdateSessionRequest, + ) +} + +internal data class CreateSessionRequest( + val onesignalId: String, + val subscriptionId: String, + val deviceType: Int, + /** Unix time in seconds, sent as ISO 8601 UTC. */ + val startTime: Long, + val idempotencyKey: String, + val directAttributionId: String? = null, +) + +internal data class UpdateSessionRequest( + val onesignalId: String, + val subscriptionId: String, + val durationSeconds: Long, + val idempotencyKey: String, + /** Unix time in seconds, sent as ISO 8601 UTC. */ + val endTime: Long? = null, +) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt new file mode 100644 index 000000000..c3220ca19 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/session/internal/session/backend/impl/SessionsBackendService.kt @@ -0,0 +1,71 @@ +package com.onesignal.session.internal.session.backend.impl + +import com.onesignal.common.DateUtils +import com.onesignal.common.exceptions.BackendException +import com.onesignal.core.internal.http.IHttpClient +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.session.internal.session.backend.CreateSessionRequest +import com.onesignal.session.internal.session.backend.ISessionsBackendService +import com.onesignal.session.internal.session.backend.UpdateSessionRequest +import org.json.JSONException +import org.json.JSONObject +import java.util.Date + +internal class SessionsBackendService( + private val httpClient: IHttpClient, +) : ISessionsBackendService { + override suspend fun createSession( + appId: String, + request: CreateSessionRequest, + ): String? { + val body = + JSONObject() + .put("onesignal_id", request.onesignalId) + .put("subscription_id", request.subscriptionId) + .put("device_type", request.deviceType) + .put("start_time", toIso8601(request.startTime)) + .put("idempotency_key", request.idempotencyKey) + request.directAttributionId?.let { body.put("direct_attribution_id", it) } + + val response = httpClient.post("apps/$appId/sessions", body) + if (!response.isSuccess) { + throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) + } + + return parseSessionId(response.payload) + } + + override suspend fun updateSession( + appId: String, + sessionId: String, + request: UpdateSessionRequest, + ) { + val body = + JSONObject() + .put("onesignal_id", request.onesignalId) + .put("subscription_id", request.subscriptionId) + .put("duration_seconds", request.durationSeconds) + .put("idempotency_key", request.idempotencyKey) + request.endTime?.let { body.put("end_time", toIso8601(it)) } + + val response = httpClient.patch("apps/$appId/sessions/$sessionId", body) + if (!response.isSuccess) { + throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds) + } + } + + private fun toIso8601(epochSeconds: Long): String = DateUtils.iso8601Format().format(Date(epochSeconds * MILLIS_PER_SECOND)) + + private fun parseSessionId(payload: String?): String? = + try { + val sessionId = payload?.let { JSONObject(it) }?.optJSONObject("data")?.opt("session_id") as? String + sessionId?.takeIf { it.isNotEmpty() } + } catch (e: JSONException) { + Logging.warn("SessionsBackendService: unable to parse create session response", e) + null + } + + private companion object { + const val MILLIS_PER_SECOND = 1000L + } +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt new file mode 100644 index 000000000..646852b96 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/session/internal/session/backend/SessionsBackendServiceTests.kt @@ -0,0 +1,181 @@ +package com.onesignal.session.internal.session.backend + +import com.onesignal.common.exceptions.BackendException +import com.onesignal.core.internal.http.HttpResponse +import com.onesignal.core.internal.http.IHttpClient +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.session.internal.session.backend.impl.SessionsBackendService +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk + +private val createRequest = + CreateSessionRequest( + onesignalId = "onesignalId", + subscriptionId = "subscriptionId", + deviceType = 1, + startTime = 1_700_000_000L, + idempotencyKey = "create-key", + ) + +private val updateRequest = + UpdateSessionRequest( + onesignalId = "onesignalId", + subscriptionId = "subscriptionId", + durationSeconds = 42L, + idempotencyKey = "update-key", + ) + +class SessionsBackendServiceTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + test("create session posts the request body") { + // Given + val http = mockk() + coEvery { http.post(any(), any()) } returns HttpResponse(202, """{"data":{"session_id":"server-id"}}""") + val service = SessionsBackendService(http) + + // When + service.createSession("appId", createRequest) + + // Then + coVerify { + http.post( + "apps/appId/sessions", + withArg { + it.getString("onesignal_id") shouldBe "onesignalId" + it.getString("subscription_id") shouldBe "subscriptionId" + it.getInt("device_type") shouldBe 1 + it.getString("start_time") shouldBe "2023-11-14T22:13:20.000Z" + it.getString("idempotency_key") shouldBe "create-key" + it.has("direct_attribution_id") shouldBe false + }, + ) + } + } + + test("create session includes direct attribution when set") { + // Given + val http = mockk() + coEvery { http.post(any(), any()) } returns HttpResponse(202, """{"data":{"session_id":"server-id"}}""") + val service = SessionsBackendService(http) + + // When + service.createSession("appId", createRequest.copy(directAttributionId = "notificationId")) + + // Then + coVerify { + http.post( + "apps/appId/sessions", + withArg { it.getString("direct_attribution_id") shouldBe "notificationId" }, + ) + } + } + + test("create session returns the session ID from a 202") { + // Given + val http = mockk() + coEvery { http.post(any(), any()) } returns HttpResponse(202, """{"data":{"session_id":"server-id"}}""") + val service = SessionsBackendService(http) + + // When + val result = service.createSession("appId", createRequest) + + // Then + result shouldBe "server-id" + } + + test("create session returns null for a success response without a session ID") { + listOf( + null, + "", + "not json", + "{}", + """{"data":{}}""", + """{"data":{"session_id":""}}""", + """{"data":{"session_id":null}}""", + """{"data":{"session_id":123}}""", + """{"data":"server-id"}""", + ).forEach { payload -> + // Given + val http = mockk() + coEvery { http.post(any(), any()) } returns HttpResponse(202, payload) + val service = SessionsBackendService(http) + + // When + val result = service.createSession("appId", createRequest) + + // Then + result shouldBe null + } + } + + test("update session patches the request body") { + // Given + val http = mockk() + coEvery { http.patch(any(), any()) } returns HttpResponse(202, null) + val service = SessionsBackendService(http) + + // When + service.updateSession("appId", "server-id", updateRequest) + + // Then + coVerify { + http.patch( + "apps/appId/sessions/server-id", + withArg { + it.getString("onesignal_id") shouldBe "onesignalId" + it.getString("subscription_id") shouldBe "subscriptionId" + it.getLong("duration_seconds") shouldBe 42L + it.getString("idempotency_key") shouldBe "update-key" + it.has("end_time") shouldBe false + }, + ) + } + } + + test("update session includes end time when set") { + // Given + val http = mockk() + coEvery { http.patch(any(), any()) } returns HttpResponse(202, null) + val service = SessionsBackendService(http) + + // When + service.updateSession("appId", "server-id", updateRequest.copy(endTime = 1_700_000_042L)) + + // Then + coVerify { + http.patch( + "apps/appId/sessions/server-id", + withArg { it.getString("end_time") shouldBe "2023-11-14T22:14:02.000Z" }, + ) + } + } + + test("failures throw BackendException with the status code and Retry-After") { + listOf(-1, 0, 400, 404, 422, 429, 503).forEach { statusCode -> + // Given + val http = mockk() + coEvery { http.post(any(), any()) } returns HttpResponse(statusCode, "error", retryAfterSeconds = 30) + coEvery { http.patch(any(), any()) } returns HttpResponse(statusCode, "error", retryAfterSeconds = 30) + val service = SessionsBackendService(http) + + // When + val createException = shouldThrow { service.createSession("appId", createRequest) } + val updateException = shouldThrow { service.updateSession("appId", "server-id", updateRequest) } + + // Then + listOf(createException, updateException).forEach { + it.statusCode shouldBe statusCode + it.response shouldBe "error" + it.retryAfterSeconds shouldBe 30 + } + } + } +})