feat(auth): register official client devices

This commit is contained in:
tapframe 2026-07-26 20:34:08 +05:30
parent 8937dc7b6b
commit b3f22a6be2
5 changed files with 169 additions and 0 deletions

View file

@ -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",
)
}

View file

@ -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()
}
}

View file

@ -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"
}
}

View file

@ -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"),
)
}
}

View file

@ -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,
)
}