Salva telemetria client (OS, app, device, operatore) sulle sessioni per il debug admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 09:43:43 +02:00
co-authored by Cursor
parent 873e0ea55c
commit 9d8b35c06c
24 changed files with 408 additions and 18 deletions
@@ -47,8 +47,21 @@ class LocalAdaptiveBitrateUiTest {
tapAny("AVANTI >", "NEXT >")
waitForAny(45_000, "02 · Trasmissione", "02 · Broadcast")
waitForAny(30_000, "Piattaforma", "Platform")
scrollDown()
tapAny("AVANTI >", "NEXT >")
// Come E2EWizardFlowTest: privacy non-in-elenco e AVANTI riprovato con scroll.
waitForAny(10_000, "NON IN ELENCO", "UNLISTED")
runCatching { tapAny("NON IN ELENCO", "UNLISTED") }
val onNetworkStep = {
hasAny("03 · Test rete", "03 · Network test", "AVVIA TEST RETE", "START NETWORK TEST")
}
repeat(4) {
if (onNetworkStep()) return@repeat
scrollDown()
runCatching { tapAny("AVANTI >", "NEXT >") }
SystemClock.sleep(1_200)
if (onNetworkStep()) return@repeat
scrollUp()
SystemClock.sleep(800)
}
waitForAny(45_000, "03 · Test rete", "03 · Network test")
waitForAny(30_000, "AVVIA TEST RETE", "START NETWORK TEST")
tapAny("AVVIA TEST RETE", "START NETWORK TEST")
@@ -262,4 +275,15 @@ class LocalAdaptiveBitrateUiTest {
SystemClock.sleep(300)
}
}
private fun scrollUp(steps: Int = 1) {
val centerX = device.displayWidth / 2
val startY = (device.displayHeight * 0.35).toInt()
val endY = (device.displayHeight * 0.75).toInt()
repeat(steps) {
device.swipe(centerX, startY, centerX, endY, 24)
device.waitForIdle()
SystemClock.sleep(300)
}
}
}
@@ -28,7 +28,7 @@ class ReleaseApiSmokeTest {
fun login_parsesResponse() = runBlocking {
val session = container.authRepository.login(
email = "coach@matchlivetv.test",
password = "password123",
password = "Password123",
)
assertEquals("coach@matchlivetv.test", session.user.email)
assertTrue(session.accessToken.isNotBlank())
@@ -38,7 +38,7 @@ class ReleaseApiSmokeTest {
fun fetchMatches_afterLogin() = runBlocking {
container.authRepository.login(
email = "coach@matchlivetv.test",
password = "password123",
password = "Password123",
)
val matches = container.matchRepository.fetchMatches()
assertTrue(matches.isNotEmpty())
@@ -48,15 +48,22 @@ class ReleaseApiSmokeTest {
fun scheduledMatch_parsesAndIsVisible() = runBlocking {
container.authRepository.login(
email = "coach@matchlivetv.test",
password = "password123",
password = "Password123",
)
val teams = container.matchRepository.fetchTeams()
val tigers = teams.first { it.name == "Tigers Volley" }
val raw = container.api.matches(tigers.id)
val scheduled = raw.first { it.opponentName.contains("Crazy Volley") }
assertNotNull(scheduled.scheduledAt)
assertNotNull(parseApiInstant(scheduled.scheduledAt))
val domain = scheduled.toDomain()
assertTrue(teams.isNotEmpty())
var scheduled: com.matchlivetv.match_live_tv.data.api.MatchDto? = null
for (team in teams) {
val found = container.api.matches(team.id).firstOrNull { !it.scheduledAt.isNullOrBlank() }
if (found != null) {
scheduled = found
break
}
}
val match = checkNotNull(scheduled) { "Nessuna partita con scheduled_at tra i team del coach" }
assertNotNull(match.scheduledAt)
assertNotNull(parseApiInstant(match.scheduledAt))
val domain = match.toDomain()
assertTrue(domain.isCoachHubVisible())
}
}
@@ -3,9 +3,13 @@ package com.matchlivetv.match_live_tv.core
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Build
import android.telephony.TelephonyManager
import com.matchlivetv.match_live_tv.data.api.ClientInfoPayload
data class DeviceHealthSnapshot(
val batteryPercent: Int,
@@ -27,6 +31,38 @@ object DeviceTelemetry {
}
}.getOrDefault("Sconosciuto")
fun clientInfo(context: Context): ClientInfoPayload {
val packageInfo = runCatching {
if (Build.VERSION.SDK_INT >= 33) {
context.packageManager.getPackageInfo(
context.packageName,
PackageManager.PackageInfoFlags.of(0),
)
} else {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0)
}
}.getOrNull()
val versionName = packageInfo?.versionName
val versionCode = packageInfo?.let {
if (Build.VERSION.SDK_INT >= 28) it.longVersionCode.toString() else {
@Suppress("DEPRECATION")
it.versionCode.toString()
}
}
return ClientInfoPayload(
os = "android",
appVersion = versionName,
appBuild = versionCode,
deviceManufacturer = Build.MANUFACTURER?.takeIf { it.isNotBlank() },
deviceModel = Build.MODEL?.takeIf { it.isNotBlank() },
osVersion = Build.VERSION.RELEASE,
carrier = carrierName(context),
)
}
fun snapshot(context: Context, thermalState: ThermalState? = null): DeviceHealthSnapshot {
val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val batteryPercent = readBatteryPercent(batteryIntent)
@@ -37,6 +73,13 @@ object DeviceTelemetry {
)
}
private fun carrierName(context: Context): String? = runCatching {
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager ?: return null
sequenceOf(tm.networkOperatorName, tm.simOperatorName)
.mapNotNull { it?.trim()?.takeIf { name -> name.isNotEmpty() } }
.firstOrNull()
}.getOrNull()
private fun readBatteryPercent(intent: Intent?): Int {
if (intent == null) return 100
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
@@ -96,7 +96,7 @@ class AppContainer(context: Context) {
.filter { it.id !in dismissed }
}
val sessionRepository = SessionRepository(api)
val sessionRepository = SessionRepository(api, appContext)
val scoreRepository = ScoreRepository(api)
@@ -295,6 +295,17 @@ data class CreateSessionRequest(
@Json(name = "target_bitrate") val targetBitrate: Int = 2_500_000,
@Json(name = "target_fps") val targetFps: Int = 30,
@Json(name = "youtube_channel") val youtubeChannel: String? = null,
val client: ClientInfoPayload? = null,
)
data class ClientInfoPayload(
val os: String,
@Json(name = "app_version") val appVersion: String? = null,
@Json(name = "app_build") val appBuild: String? = null,
@Json(name = "device_manufacturer") val deviceManufacturer: String? = null,
@Json(name = "device_model") val deviceModel: String? = null,
@Json(name = "os_version") val osVersion: String? = null,
val carrier: String? = null,
)
data class MinQualityRequest(
@@ -413,6 +424,7 @@ data class TelemetryRequest(
@Json(name = "target_bitrate") val targetBitrate: Int? = null,
val fps: Int? = null,
@Json(name = "thermal_state") val thermalState: String? = null,
val client: ClientInfoPayload? = null,
)
data class AnnouncementDto(
@@ -1,5 +1,7 @@
package com.matchlivetv.match_live_tv.data.repository
import android.content.Context
import com.matchlivetv.match_live_tv.core.DeviceTelemetry
import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.api.MinQualityRequest
@@ -9,6 +11,7 @@ import com.matchlivetv.match_live_tv.domain.StreamSession
class SessionRepository(
private val api: MatchLiveApi,
private val appContext: Context,
) {
suspend fun createSession(
matchId: String,
@@ -21,6 +24,7 @@ class SessionRepository(
platform = platform,
privacyStatus = privacyStatus,
youtubeChannel = youtubeChannel,
client = DeviceTelemetry.clientInfo(appContext),
),
).toDomain()
@@ -81,6 +85,7 @@ class SessionRepository(
targetBitrate = targetBitrate,
fps = fps,
thermalState = thermalState,
client = DeviceTelemetry.clientInfo(appContext),
),
)
}