diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.android.kt new file mode 100644 index 00000000..3779c281 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.android.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.core.auth + +import android.os.Build + +internal actual fun currentDeviceClientMetadata(): DeviceClientMetadata { + val deviceName = formatDeviceName( + manufacturer = Build.MANUFACTURER.orEmpty(), + model = Build.MODEL.orEmpty(), + fallback = "Android device", + ) + val osVersion = Build.VERSION.RELEASE.orEmpty() + .trim() + .ifBlank { Build.VERSION.SDK_INT.toString() } + + return DeviceClientMetadata( + deviceName = deviceName, + platform = "Android $osVersion", + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index dd8cc3e7..d0d41f53 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -86,6 +86,7 @@ import coil3.svg.SvgDecoder import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.auth.DeviceSessionRegistration import com.nuvio.app.core.deeplink.AppDeepLink import com.nuvio.app.core.deeplink.AppDeepLinkRepository import com.nuvio.app.core.network.NetworkCondition @@ -488,6 +489,11 @@ fun App( NetworkStatusRepository.uiState }.collectAsStateWithLifecycle() + LaunchedEffect(authState) { + if (!ownsAppRuntime) return@LaunchedEffect + DeviceSessionRegistration.registerIfAuthenticated(force = true) + } + LaunchedEffect( profileState.activeProfile?.profileIndex, profileState.activeProfile?.name, @@ -1051,6 +1057,7 @@ private fun MainAppContent( if (!ownsAppRuntime) return@LaunchedEffect AppForegroundMonitor.events().collect { NetworkStatusRepository.requestForegroundRefresh() + DeviceSessionRegistration.registerIfAuthenticated() } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.kt new file mode 100644 index 00000000..df785b81 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.kt @@ -0,0 +1,90 @@ +package com.nuvio.app.core.auth + +import co.touchlab.kermit.Logger +import com.nuvio.app.core.build.AppVersionConfig +import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.core.sync.SyncClientIdentity +import io.github.jan.supabase.postgrest.postgrest +import io.github.jan.supabase.postgrest.rpc +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.time.Duration.Companion.minutes +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +private const val CLIENT_NAME = "Nuvio Mobile" +private val REGISTRATION_INTERVAL = 15.minutes + +internal data class DeviceClientMetadata( + val deviceName: String, + val platform: String, +) + +internal expect fun currentDeviceClientMetadata(): DeviceClientMetadata + +object DeviceSessionRegistration { + private val log = Logger.withTag("DeviceSessionRegistration") + private val registrationMutex = Mutex() + private var lastRegistration: TimeMark? = null + + suspend fun registerIfAuthenticated(force: Boolean = false): Boolean { + val authState = AuthRepository.state.value as? AuthState.Authenticated ?: return false + if (authState.isAnonymous) return false + + return registrationMutex.withLock { + if (!force && lastRegistration?.elapsedNow()?.let { it < REGISTRATION_INTERVAL } == true) { + return@withLock true + } + + val metadata = currentDeviceClientMetadata() + val params = buildDeviceRegistrationParams( + installationId = SyncClientIdentity.currentClientId(), + clientVersion = AppVersionConfig.VERSION_NAME, + metadata = metadata, + ) + + try { + SupabaseProvider.client.postgrest.rpc("register_current_device", params) + lastRegistration = TimeSource.Monotonic.markNow() + true + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + log.w(error) { "Device session registration failed" } + false + } + } + } +} + +internal fun buildDeviceRegistrationParams( + installationId: String, + clientVersion: String, + metadata: DeviceClientMetadata, +): JsonObject = buildJsonObject { + put("p_installation_id", installationId) + put("p_client_name", CLIENT_NAME) + put("p_client_version", clientVersion) + put("p_platform", metadata.platform) + put("p_device_name", metadata.deviceName) +} + +internal fun formatDeviceName( + manufacturer: String, + model: String, + fallback: String, +): String { + val normalizedManufacturer = manufacturer.trim() + val normalizedModel = model.trim() + + return when { + normalizedModel.isBlank() -> fallback + normalizedManufacturer.isBlank() -> normalizedModel + normalizedModel.startsWith(normalizedManufacturer, ignoreCase = true) -> normalizedModel + else -> "$normalizedManufacturer $normalizedModel" + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistrationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistrationTest.kt new file mode 100644 index 00000000..065f43d4 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistrationTest.kt @@ -0,0 +1,36 @@ +package com.nuvio.app.core.auth + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DeviceSessionRegistrationTest { + @Test + fun buildsOfficialClientRegistrationPayload() { + val params = buildDeviceRegistrationParams( + installationId = "nuvio-mobile-installation", + clientVersion = "1.2.3", + metadata = DeviceClientMetadata( + deviceName = "Google Pixel 9", + platform = "Android 16", + ), + ) + + assertEquals("nuvio-mobile-installation", params.getValue("p_installation_id").toString().trim('"')) + assertEquals("Nuvio Mobile", params.getValue("p_client_name").toString().trim('"')) + assertEquals("1.2.3", params.getValue("p_client_version").toString().trim('"')) + assertEquals("Android 16", params.getValue("p_platform").toString().trim('"')) + assertEquals("Google Pixel 9", params.getValue("p_device_name").toString().trim('"')) + } + + @Test + fun combinesManufacturerAndModelWithoutRepeatingManufacturer() { + assertEquals( + "Samsung SM-S918B", + formatDeviceName("Samsung", "SM-S918B", "Android device"), + ) + assertEquals( + "Google Pixel 9", + formatDeviceName("Google", "Google Pixel 9", "Android device"), + ) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.ios.kt new file mode 100644 index 00000000..0259ebf2 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/auth/DeviceSessionRegistration.ios.kt @@ -0,0 +1,17 @@ +package com.nuvio.app.core.auth + +import platform.UIKit.UIDevice + +internal actual fun currentDeviceClientMetadata(): DeviceClientMetadata { + val device = UIDevice.currentDevice + val deviceName = device.name + .trim() + .ifBlank { device.localizedModel.trim() } + .ifBlank { "Apple device" } + val platform = "${device.systemName()} ${device.systemVersion}".trim() + + return DeviceClientMetadata( + deviceName = deviceName, + platform = platform, + ) +}