From 9d8b35c06c0140f56e6ad9e4d05c9502ec478ff8 Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Wed, 26 Aug 2026 09:43:43 +0200 Subject: [PATCH] Salva telemetria client (OS, app, device, operatore) sulle sessioni per il debug admin. Co-authored-by: Cursor --- .../api/v1/stream_sessions_controller.rb | 8 ++- backend/app/helpers/admin_helper.rb | 23 ++++++ .../services/sessions/apply_client_info.rb | 72 +++++++++++++++++++ backend/app/services/sessions/create.rb | 8 ++- .../app/views/admin/dashboard/index.html.erb | 2 + .../app/views/admin/sessions/index.html.erb | 2 + .../app/views/admin/sessions/show.html.erb | 32 +++++++++ backend/config/locales/admin.de.yml | 7 ++ backend/config/locales/admin.en.yml | 7 ++ backend/config/locales/admin.es.yml | 7 ++ backend/config/locales/admin.fr.yml | 7 ++ backend/config/locales/admin.it.yml | 7 ++ ...add_client_telemetry_to_stream_sessions.rb | 15 ++++ backend/db/schema.rb | 9 ++- .../sessions/apply_client_info_spec.rb | 45 ++++++++++++ .../LocalAdaptiveBitrateUiTest.kt | 28 +++++++- .../match_live_tv/ReleaseApiSmokeTest.kt | 25 ++++--- .../match_live_tv/core/DeviceTelemetry.kt | 43 +++++++++++ .../match_live_tv/data/AppContainer.kt | 2 +- .../match_live_tv/data/api/ApiDtos.kt | 12 ++++ .../data/repository/SessionRepository.kt | 5 ++ .../MatchLiveTv/Core/DeviceTelemetry.swift | 52 ++++++++++++++ native/ios/MatchLiveTv/Data/API/ApiDtos.swift | 2 + .../Data/Repository/SessionRepository.swift | 6 +- 24 files changed, 408 insertions(+), 18 deletions(-) create mode 100644 backend/app/services/sessions/apply_client_info.rb create mode 100644 backend/db/migrate/20260826090000_add_client_telemetry_to_stream_sessions.rb create mode 100644 backend/spec/services/sessions/apply_client_info_spec.rb diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb index 3cbe615..4440407 100644 --- a/backend/app/controllers/api/v1/stream_sessions_controller.rb +++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb @@ -77,6 +77,7 @@ module Api thermal_state: sanitized_thermal_state, last_seen_at: Time.current ) + Sessions::ApplyClientInfo.call(@session, params[:client]) if params[:client].present? sync_publisher_when_streaming!(params[:fps].to_f) SessionChannel.broadcast_message(@session, state.as_cable_payload) head :no_content @@ -180,7 +181,12 @@ module Api end def session_params - params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel) + params.permit( + :platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel, + client: %i[os client_os app_version app_build version build build_number + device_manufacturer manufacturer device_model model + os_version system_version carrier network_operator operator] + ) end def score_sync_params diff --git a/backend/app/helpers/admin_helper.rb b/backend/app/helpers/admin_helper.rb index 8d4ffc4..286f597 100644 --- a/backend/app/helpers/admin_helper.rb +++ b/backend/app/helpers/admin_helper.rb @@ -81,6 +81,29 @@ module AdminHelper end end + def admin_session_client_os_label(session) + case session.client_os.to_s + when "android" then "Android" + when "ios" then "iOS" + else I18n.t("admin.common.dash") + end + end + + def admin_session_client_summary(session) + parts = [] + parts << admin_session_client_os_label(session) if session.client_os.present? + if session.app_version.present? + ver = session.app_version + ver = "#{ver} (#{session.app_build})" if session.app_build.present? + parts << "app #{ver}" + end + device = [session.device_manufacturer, session.device_model].compact_blank.join(" ") + parts << device if device.present? + parts << "OS #{session.os_version}" if session.os_version.present? + parts << session.carrier if session.carrier.present? + parts.presence&.join(" · ") || I18n.t("admin.common.dash") + end + def admin_format_event_meta(metadata) return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if metadata.blank? diff --git a/backend/app/services/sessions/apply_client_info.rb b/backend/app/services/sessions/apply_client_info.rb new file mode 100644 index 0000000..a835068 --- /dev/null +++ b/backend/app/services/sessions/apply_client_info.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Sessions + # Normalizza e applica fingerprint del client (OS, app, device, operatore) + # sulla sessione, a create e/o a ogni telemetry. + class ApplyClientInfo + OS_VALUES = %w[android ios].freeze + MAX_LEN = 80 + + ATTRS = %i[ + client_os app_version app_build device_manufacturer device_model os_version carrier + ].freeze + + def self.call(session, raw) + new(session, raw).call + end + + def initialize(session, raw) + @session = session + @raw = normalize_hash(raw) + end + + def call + attrs = extract_attrs + return @session if attrs.empty? + + @session.assign_attributes(attrs) + @session.save! if @session.persisted? && @session.changed? + @session + end + + private + + def normalize_hash(raw) + return {} if raw.blank? + + data = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw + data = data.to_h if data.respond_to?(:to_h) + data.with_indifferent_access + rescue StandardError + {} + end + + def extract_attrs + attrs = {} + + os = @raw[:os].presence || @raw[:client_os].presence + os = os.to_s.downcase.strip + attrs[:client_os] = os if OS_VALUES.include?(os) + + { + app_version: %i[app_version version], + app_build: %i[app_build build build_number], + device_manufacturer: %i[device_manufacturer manufacturer], + device_model: %i[device_model model], + os_version: %i[os_version system_version], + carrier: %i[carrier network_operator operator] + }.each do |column, keys| + value = keys.map { |k| @raw[k] }.find(&:present?) + next if value.blank? + + attrs[column] = truncate(value.to_s.strip) + end + + attrs + end + + def truncate(value) + value.bytesize <= MAX_LEN ? value : value.byteslice(0, MAX_LEN) + end + end +end diff --git a/backend/app/services/sessions/create.rb b/backend/app/services/sessions/create.rb index c517032..2ad8476 100644 --- a/backend/app/services/sessions/create.rb +++ b/backend/app/services/sessions/create.rb @@ -26,6 +26,7 @@ module Sessions target_fps: @params[:target_fps] || 30, status: "idle" ) + Sessions::ApplyClientInfo.call(session, @params[:client]) youtube_channel = nil if session.platform == "youtube" @@ -46,8 +47,11 @@ module Sessions { created: true, platform: session.platform, - stream_node: session.stream_node&.slug - } + stream_node: session.stream_node&.slug, + client_os: session.client_os, + app_version: session.app_version, + device_model: session.device_model + }.compact ) end diff --git a/backend/app/views/admin/dashboard/index.html.erb b/backend/app/views/admin/dashboard/index.html.erb index d3c4b3a..0bd4412 100644 --- a/backend/app/views/admin/dashboard/index.html.erb +++ b/backend/app/views/admin/dashboard/index.html.erb @@ -107,6 +107,7 @@ <%= t("admin.dashboard.sessions.table.match") %> <%= t("admin.dashboard.sessions.table.status") %> + <%= t("admin.dashboard.sessions.table.client") %> <%= t("admin.dashboard.sessions.table.ingest") %> <%= t("admin.dashboard.sessions.table.start") %> <%= t("admin.dashboard.sessions.table.link") %> @@ -118,6 +119,7 @@ <%= s.match.team.name %> vs <%= s.match.opponent_name %> <%= s.status %> + <%= admin_session_client_summary(s) %> <%= render "admin/sessions/ingest_cell", session: s %> <%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %> diff --git a/backend/app/views/admin/sessions/index.html.erb b/backend/app/views/admin/sessions/index.html.erb index 19623a4..da18444 100644 --- a/backend/app/views/admin/sessions/index.html.erb +++ b/backend/app/views/admin/sessions/index.html.erb @@ -83,6 +83,7 @@ <%= t("admin.sessions.index.table.ended") %> <%= t("admin.sessions.index.table.duration") %> <%= t("admin.sessions.index.table.ingest") %> + <%= t("admin.sessions.index.table.client") %> <%= t("admin.sessions.index.table.disconnects") %> <%= t("admin.sessions.index.table.link") %> @@ -110,6 +111,7 @@ <%= s.ended_at ? admin_datetime(s.ended_at) : t("admin.common.dash") %> <%= admin_session_duration_label(s) %> <%= render "admin/sessions/ingest_cell", session: s %> + <%= admin_session_client_summary(s) %> <%= s.disconnection_count %> +
+
<%= t("admin.sessions.show.fields.client_os") %>
+
<%= admin_session_client_os_label(@session) %>
+
+
+
<%= t("admin.sessions.show.fields.app_version") %>
+
+ <% if @session.app_version.present? %> + <%= @session.app_version %> + <% if @session.app_build.present? %> + (<%= @session.app_build %>) + <% end %> + <% else %> + <%= t("admin.common.dash") %> + <% end %> +
+
+
+
<%= t("admin.sessions.show.fields.device") %>
+
+ <% device = [@session.device_manufacturer, @session.device_model].compact_blank.join(" ") %> + <%= device.presence || t("admin.common.dash") %> +
+
+
+
<%= t("admin.sessions.show.fields.os_version") %>
+
<%= @session.os_version.presence || t("admin.common.dash") %>
+
+
+
<%= t("admin.sessions.show.fields.carrier") %>
+
<%= @session.carrier.presence || t("admin.common.dash") %>
+
<%= t("admin.sessions.show.fields.privacy") %>
<%= @session.privacy_status %>
diff --git a/backend/config/locales/admin.de.yml b/backend/config/locales/admin.de.yml index 43e0105..81d6d59 100644 --- a/backend/config/locales/admin.de.yml +++ b/backend/config/locales/admin.de.yml @@ -111,6 +111,7 @@ de: table: match: Spiel status: Status + client: Client ingest: Ingest start: Start link: Link @@ -276,6 +277,7 @@ de: duration: Dauer ingest: Ingest disconnects: Verbindungsabbrüche + client: Client link: Link detail: Details regia: Regie @@ -308,6 +310,11 @@ de: opponent: Gegner operator: Operator platform: Plattform + client_os: System + app_version: App-Version + device: Gerät + os_version: OS-Version + carrier: Mobilfunkanbieter privacy: Privacy quality: Qualität min_quality: Min. Qualität diff --git a/backend/config/locales/admin.en.yml b/backend/config/locales/admin.en.yml index 5e2748c..1faac8b 100644 --- a/backend/config/locales/admin.en.yml +++ b/backend/config/locales/admin.en.yml @@ -111,6 +111,7 @@ en: table: match: Match status: Status + client: Client ingest: Ingest start: Start link: Link @@ -276,6 +277,7 @@ en: duration: Duration ingest: Ingest disconnects: Disconnects + client: Client link: Link detail: Details regia: Control @@ -308,6 +310,11 @@ en: opponent: Opponent operator: Operator platform: Platform + client_os: OS + app_version: App version + device: Device + os_version: OS version + carrier: Carrier privacy: Privacy quality: Quality min_quality: Min quality diff --git a/backend/config/locales/admin.es.yml b/backend/config/locales/admin.es.yml index 8a29c1c..e69dc56 100644 --- a/backend/config/locales/admin.es.yml +++ b/backend/config/locales/admin.es.yml @@ -111,6 +111,7 @@ es: table: match: Partido status: Estado + client: Cliente ingest: Ingest start: Inicio link: Enlace @@ -276,6 +277,7 @@ es: duration: Duración ingest: Ingest disconnects: Desconexiones + client: Cliente link: Enlace detail: Detalle regia: Regie @@ -308,6 +310,11 @@ es: opponent: Rival operator: Operador platform: Plataforma + client_os: Sistema + app_version: Versión app + device: Dispositivo + os_version: Versión OS + carrier: Operador móvil privacy: Privacidad quality: Calidad min_quality: Calidad mínima diff --git a/backend/config/locales/admin.fr.yml b/backend/config/locales/admin.fr.yml index 56198a7..31a5fad 100644 --- a/backend/config/locales/admin.fr.yml +++ b/backend/config/locales/admin.fr.yml @@ -111,6 +111,7 @@ fr: table: match: Match status: Statut + client: Client ingest: Ingest start: Début link: Lien @@ -276,6 +277,7 @@ fr: duration: Durée ingest: Ingest disconnects: Déconnexions + client: Client link: Lien detail: Détail regia: Régie @@ -308,6 +310,11 @@ fr: opponent: Adversaire operator: Opérateur platform: Plateforme + client_os: Système + app_version: Version app + device: Appareil + os_version: Version OS + carrier: Opérateur mobile privacy: Confidentialité quality: Qualité min_quality: Qualité mini diff --git a/backend/config/locales/admin.it.yml b/backend/config/locales/admin.it.yml index 4f142fe..c15b03e 100644 --- a/backend/config/locales/admin.it.yml +++ b/backend/config/locales/admin.it.yml @@ -115,6 +115,7 @@ it: table: match: Partita status: Stato + client: Client ingest: Ingest start: Inizio link: Link @@ -297,6 +298,7 @@ it: duration: Durata ingest: Ingest disconnects: Disconnessioni + client: Client link: Link detail: Dettaglio regia: Regia @@ -329,6 +331,11 @@ it: opponent: Avversario operator: Operatore platform: Piattaforma + client_os: Sistema + app_version: Versione app + device: Dispositivo + os_version: Versione OS + carrier: Operatore telefonico privacy: Privacy quality: Qualità min_quality: Qualità minima diff --git a/backend/db/migrate/20260826090000_add_client_telemetry_to_stream_sessions.rb b/backend/db/migrate/20260826090000_add_client_telemetry_to_stream_sessions.rb new file mode 100644 index 0000000..739ec28 --- /dev/null +++ b/backend/db/migrate/20260826090000_add_client_telemetry_to_stream_sessions.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddClientTelemetryToStreamSessions < ActiveRecord::Migration[7.2] + def change + change_table :stream_sessions, bulk: true do |t| + t.string :client_os + t.string :app_version + t.string :app_build + t.string :device_manufacturer + t.string :device_model + t.string :os_version + t.string :carrier + end + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 179fcc6..87d018e 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do +ActiveRecord::Schema[7.2].define(version: 2026_08_26_090000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -427,6 +427,13 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do t.uuid "stream_node_id" t.string "min_quality_preset", default: "auto", null: false t.boolean "audio_muted", default: false, null: false + t.string "client_os" + t.string "app_version" + t.string "app_build" + t.string "device_manufacturer" + t.string "device_model" + t.string "os_version" + t.string "carrier" t.index ["match_id"], name: "index_stream_sessions_on_match_id" t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true diff --git a/backend/spec/services/sessions/apply_client_info_spec.rb b/backend/spec/services/sessions/apply_client_info_spec.rb new file mode 100644 index 0000000..28aff98 --- /dev/null +++ b/backend/spec/services/sessions/apply_client_info_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Sessions::ApplyClientInfo do + let!(:user) { User.create!(email: "client-info@test.it", name: "U", password: "Password123", role: "coach") } + let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "T", sport: "volleyball") } + let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") } + let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "idle") } + + it "applica i campi client sulla sessione" do + described_class.call(session, { + os: "android", + app_version: "1.4.0", + app_build: "42", + device_manufacturer: "Samsung", + device_model: "SM-G991B", + os_version: "14", + carrier: "TIM" + }) + + session.reload + expect(session.client_os).to eq("android") + expect(session.app_version).to eq("1.4.0") + expect(session.app_build).to eq("42") + expect(session.device_manufacturer).to eq("Samsung") + expect(session.device_model).to eq("SM-G991B") + expect(session.os_version).to eq("14") + expect(session.carrier).to eq("TIM") + end + + it "ignora os sconosciuti" do + described_class.call(session, { os: "windows" }) + expect(session.reload.client_os).to be_nil + end + + it "assegna senza salvare su record non persistito" do + draft = StreamSession.new(match: match, user: user, platform: "matchlivetv", status: "idle") + described_class.call(draft, { os: "ios", app_version: "2.0.0" }) + expect(draft).not_to be_persisted + expect(draft.client_os).to eq("ios") + expect(draft.app_version).to eq("2.0.0") + end +end diff --git a/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/LocalAdaptiveBitrateUiTest.kt b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/LocalAdaptiveBitrateUiTest.kt index 81e0c82..a177693 100644 --- a/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/LocalAdaptiveBitrateUiTest.kt +++ b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/LocalAdaptiveBitrateUiTest.kt @@ -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) + } + } } diff --git a/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/ReleaseApiSmokeTest.kt b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/ReleaseApiSmokeTest.kt index a374a15..235ced7 100644 --- a/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/ReleaseApiSmokeTest.kt +++ b/native/android/app/src/androidTest/kotlin/com/matchlivetv/match_live_tv/ReleaseApiSmokeTest.kt @@ -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()) } } diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt index 17bcb4a..7a59238 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/DeviceTelemetry.kt @@ -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) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt index 3b17120..7c11171 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt @@ -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) diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt index f9f7b28..0d357f4 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt @@ -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( diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt index f3cbea1..b076446 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt @@ -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), ), ) } diff --git a/native/ios/MatchLiveTv/Core/DeviceTelemetry.swift b/native/ios/MatchLiveTv/Core/DeviceTelemetry.swift index eaf1e0e..c36f820 100644 --- a/native/ios/MatchLiveTv/Core/DeviceTelemetry.swift +++ b/native/ios/MatchLiveTv/Core/DeviceTelemetry.swift @@ -1,6 +1,7 @@ import Foundation import UIKit import Network +import CoreTelephony struct DeviceHealth: Sendable { let batteryPercent: Int @@ -8,6 +9,16 @@ struct DeviceHealth: Sendable { let networkType: String } +struct ClientInfoPayload: Encodable, Sendable { + let os: String + let appVersion: String? + let appBuild: String? + let deviceManufacturer: String? + let deviceModel: String? + let osVersion: String? + let carrier: String? +} + enum DeviceTelemetry { static func snapshot(thermalState: ThermalState? = nil) -> DeviceHealth { UIDevice.current.isBatteryMonitoringEnabled = true @@ -20,6 +31,24 @@ enum DeviceTelemetry { ) } + static func clientInfo() -> ClientInfoPayload { + let bundle = Bundle.main + let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + let build = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String + let model = UIDevice.current.model + // Prefer machine identifier when available (e.g. iPhone15,2) + let machine = utsnameMachine() + return ClientInfoPayload( + os: "ios", + appVersion: version, + appBuild: build, + deviceManufacturer: "Apple", + deviceModel: machine ?? model, + osVersion: UIDevice.current.systemVersion, + carrier: carrierName() + ) + } + private static func currentNetworkType() -> String { let monitor = NWPathMonitor() let semaphore = DispatchSemaphore(value: 0) @@ -40,4 +69,27 @@ enum DeviceTelemetry { monitor.cancel() return result } + + private static func carrierName() -> String? { + let info = CTTelephonyNetworkInfo() + if let providers = info.serviceSubscriberCellularProviders { + for carrier in providers.values { + if let name = carrier.carrierName?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty { + return name + } + } + } + return nil + } + + private static func utsnameMachine() -> String? { + var systemInfo = utsname() + uname(&systemInfo) + return withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { + String(validatingUTF8: $0) + } + } + } } diff --git a/native/ios/MatchLiveTv/Data/API/ApiDtos.swift b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift index 22f5779..1ecc34a 100644 --- a/native/ios/MatchLiveTv/Data/API/ApiDtos.swift +++ b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift @@ -435,6 +435,7 @@ struct CreateSessionRequest: Encodable { let targetBitrate: Int let targetFps: Int let youtubeChannel: String? + let client: ClientInfoPayload? } struct AudioMuteRequest: Encodable { @@ -531,6 +532,7 @@ struct TelemetryRequest: Encodable { let targetBitrate: Int? let fps: Int? let thermalState: String? + let client: ClientInfoPayload? } extension ScoringRules { diff --git a/native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift b/native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift index 7c92c8f..131a7d9 100644 --- a/native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift +++ b/native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift @@ -25,7 +25,8 @@ final class SessionRepository { qualityPreset: qualityPreset, targetBitrate: targetBitrate, targetFps: targetFps, - youtubeChannel: youtubeChannel + youtubeChannel: youtubeChannel, + client: DeviceTelemetry.clientInfo() ) ).toDomain() } @@ -97,7 +98,8 @@ final class SessionRepository { currentBitrate: currentBitrate, targetBitrate: targetBitrate, fps: fps, - thermalState: health.thermalState.apiValue + thermalState: health.thermalState.apiValue, + client: DeviceTelemetry.clientInfo() ) ) }