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 @@
| <%= 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 %> |
diff --git a/backend/app/views/admin/sessions/show.html.erb b/backend/app/views/admin/sessions/show.html.erb
index 9dc63aa..c8871b0 100644
--- a/backend/app/views/admin/sessions/show.html.erb
+++ b/backend/app/views/admin/sessions/show.html.erb
@@ -67,6 +67,38 @@
<%= t("admin.sessions.show.fields.platform") %>
<%= @session.platform %>
+
+ <%= 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()
)
)
}
|