Compare commits

..
2 Commits
25 changed files with 410 additions and 20 deletions
@@ -77,6 +77,7 @@ module Api
thermal_state: sanitized_thermal_state, thermal_state: sanitized_thermal_state,
last_seen_at: Time.current last_seen_at: Time.current
) )
Sessions::ApplyClientInfo.call(@session, params[:client]) if params[:client].present?
sync_publisher_when_streaming!(params[:fps].to_f) sync_publisher_when_streaming!(params[:fps].to_f)
SessionChannel.broadcast_message(@session, state.as_cable_payload) SessionChannel.broadcast_message(@session, state.as_cable_payload)
head :no_content head :no_content
@@ -180,7 +181,12 @@ module Api
end end
def session_params 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 end
def score_sync_params def score_sync_params
+23
View File
@@ -81,6 +81,29 @@ module AdminHelper
end end
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) def admin_format_event_meta(metadata)
return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if metadata.blank? return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if metadata.blank?
@@ -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
+6 -2
View File
@@ -26,6 +26,7 @@ module Sessions
target_fps: @params[:target_fps] || 30, target_fps: @params[:target_fps] || 30,
status: "idle" status: "idle"
) )
Sessions::ApplyClientInfo.call(session, @params[:client])
youtube_channel = nil youtube_channel = nil
if session.platform == "youtube" if session.platform == "youtube"
@@ -46,8 +47,11 @@ module Sessions
{ {
created: true, created: true,
platform: session.platform, 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 end
@@ -107,6 +107,7 @@
<tr> <tr>
<th><%= t("admin.dashboard.sessions.table.match") %></th> <th><%= t("admin.dashboard.sessions.table.match") %></th>
<th><%= t("admin.dashboard.sessions.table.status") %></th> <th><%= t("admin.dashboard.sessions.table.status") %></th>
<th><%= t("admin.dashboard.sessions.table.client") %></th>
<th><%= t("admin.dashboard.sessions.table.ingest") %></th> <th><%= t("admin.dashboard.sessions.table.ingest") %></th>
<th><%= t("admin.dashboard.sessions.table.start") %></th> <th><%= t("admin.dashboard.sessions.table.start") %></th>
<th><%= t("admin.dashboard.sessions.table.link") %></th> <th><%= t("admin.dashboard.sessions.table.link") %></th>
@@ -118,6 +119,7 @@
<tr> <tr>
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td> <td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td> <td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td>
<td class="muted"><%= admin_session_client_summary(s) %></td>
<td><%= render "admin/sessions/ingest_cell", session: s %></td> <td><%= render "admin/sessions/ingest_cell", session: s %></td>
<td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td> <td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td>
<td> <td>
@@ -83,6 +83,7 @@
<th><%= t("admin.sessions.index.table.ended") %></th> <th><%= t("admin.sessions.index.table.ended") %></th>
<th><%= t("admin.sessions.index.table.duration") %></th> <th><%= t("admin.sessions.index.table.duration") %></th>
<th><%= t("admin.sessions.index.table.ingest") %></th> <th><%= t("admin.sessions.index.table.ingest") %></th>
<th><%= t("admin.sessions.index.table.client") %></th>
<th><%= t("admin.sessions.index.table.disconnects") %></th> <th><%= t("admin.sessions.index.table.disconnects") %></th>
<th><%= t("admin.sessions.index.table.link") %></th> <th><%= t("admin.sessions.index.table.link") %></th>
<th></th> <th></th>
@@ -110,6 +111,7 @@
<td class="muted"><%= s.ended_at ? admin_datetime(s.ended_at) : t("admin.common.dash") %></td> <td class="muted"><%= s.ended_at ? admin_datetime(s.ended_at) : t("admin.common.dash") %></td>
<td class="muted"><%= admin_session_duration_label(s) %></td> <td class="muted"><%= admin_session_duration_label(s) %></td>
<td><%= render "admin/sessions/ingest_cell", session: s %></td> <td><%= render "admin/sessions/ingest_cell", session: s %></td>
<td class="muted admin-table-sub"><%= admin_session_client_summary(s) %></td>
<td><%= s.disconnection_count %></td> <td><%= s.disconnection_count %></td>
<td> <td>
<div class="admin-link-compact"> <div class="admin-link-compact">
@@ -67,6 +67,38 @@
<dt><%= t("admin.sessions.show.fields.platform") %></dt> <dt><%= t("admin.sessions.show.fields.platform") %></dt>
<dd><%= @session.platform %></dd> <dd><%= @session.platform %></dd>
</div> </div>
<div>
<dt><%= t("admin.sessions.show.fields.client_os") %></dt>
<dd><%= admin_session_client_os_label(@session) %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.app_version") %></dt>
<dd>
<% if @session.app_version.present? %>
<%= @session.app_version %>
<% if @session.app_build.present? %>
<span class="muted">(<%= @session.app_build %>)</span>
<% end %>
<% else %>
<%= t("admin.common.dash") %>
<% end %>
</dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.device") %></dt>
<dd>
<% device = [@session.device_manufacturer, @session.device_model].compact_blank.join(" ") %>
<%= device.presence || t("admin.common.dash") %>
</dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.os_version") %></dt>
<dd><%= @session.os_version.presence || t("admin.common.dash") %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.carrier") %></dt>
<dd><%= @session.carrier.presence || t("admin.common.dash") %></dd>
</div>
<div> <div>
<dt><%= t("admin.sessions.show.fields.privacy") %></dt> <dt><%= t("admin.sessions.show.fields.privacy") %></dt>
<dd><%= @session.privacy_status %></dd> <dd><%= @session.privacy_status %></dd>
+7
View File
@@ -111,6 +111,7 @@ de:
table: table:
match: Spiel match: Spiel
status: Status status: Status
client: Client
ingest: Ingest ingest: Ingest
start: Start start: Start
link: Link link: Link
@@ -276,6 +277,7 @@ de:
duration: Dauer duration: Dauer
ingest: Ingest ingest: Ingest
disconnects: Verbindungsabbrüche disconnects: Verbindungsabbrüche
client: Client
link: Link link: Link
detail: Details detail: Details
regia: Regie regia: Regie
@@ -308,6 +310,11 @@ de:
opponent: Gegner opponent: Gegner
operator: Operator operator: Operator
platform: Plattform platform: Plattform
client_os: System
app_version: App-Version
device: Gerät
os_version: OS-Version
carrier: Mobilfunkanbieter
privacy: Privacy privacy: Privacy
quality: Qualität quality: Qualität
min_quality: Min. Qualität min_quality: Min. Qualität
+7
View File
@@ -111,6 +111,7 @@ en:
table: table:
match: Match match: Match
status: Status status: Status
client: Client
ingest: Ingest ingest: Ingest
start: Start start: Start
link: Link link: Link
@@ -276,6 +277,7 @@ en:
duration: Duration duration: Duration
ingest: Ingest ingest: Ingest
disconnects: Disconnects disconnects: Disconnects
client: Client
link: Link link: Link
detail: Details detail: Details
regia: Control regia: Control
@@ -308,6 +310,11 @@ en:
opponent: Opponent opponent: Opponent
operator: Operator operator: Operator
platform: Platform platform: Platform
client_os: OS
app_version: App version
device: Device
os_version: OS version
carrier: Carrier
privacy: Privacy privacy: Privacy
quality: Quality quality: Quality
min_quality: Min quality min_quality: Min quality
+7
View File
@@ -111,6 +111,7 @@ es:
table: table:
match: Partido match: Partido
status: Estado status: Estado
client: Cliente
ingest: Ingest ingest: Ingest
start: Inicio start: Inicio
link: Enlace link: Enlace
@@ -276,6 +277,7 @@ es:
duration: Duración duration: Duración
ingest: Ingest ingest: Ingest
disconnects: Desconexiones disconnects: Desconexiones
client: Cliente
link: Enlace link: Enlace
detail: Detalle detail: Detalle
regia: Regie regia: Regie
@@ -308,6 +310,11 @@ es:
opponent: Rival opponent: Rival
operator: Operador operator: Operador
platform: Plataforma platform: Plataforma
client_os: Sistema
app_version: Versión app
device: Dispositivo
os_version: Versión OS
carrier: Operador móvil
privacy: Privacidad privacy: Privacidad
quality: Calidad quality: Calidad
min_quality: Calidad mínima min_quality: Calidad mínima
+7
View File
@@ -111,6 +111,7 @@ fr:
table: table:
match: Match match: Match
status: Statut status: Statut
client: Client
ingest: Ingest ingest: Ingest
start: Début start: Début
link: Lien link: Lien
@@ -276,6 +277,7 @@ fr:
duration: Durée duration: Durée
ingest: Ingest ingest: Ingest
disconnects: Déconnexions disconnects: Déconnexions
client: Client
link: Lien link: Lien
detail: Détail detail: Détail
regia: Régie regia: Régie
@@ -308,6 +310,11 @@ fr:
opponent: Adversaire opponent: Adversaire
operator: Opérateur operator: Opérateur
platform: Plateforme platform: Plateforme
client_os: Système
app_version: Version app
device: Appareil
os_version: Version OS
carrier: Opérateur mobile
privacy: Confidentialité privacy: Confidentialité
quality: Qualité quality: Qualité
min_quality: Qualité mini min_quality: Qualité mini
+7
View File
@@ -115,6 +115,7 @@ it:
table: table:
match: Partita match: Partita
status: Stato status: Stato
client: Client
ingest: Ingest ingest: Ingest
start: Inizio start: Inizio
link: Link link: Link
@@ -297,6 +298,7 @@ it:
duration: Durata duration: Durata
ingest: Ingest ingest: Ingest
disconnects: Disconnessioni disconnects: Disconnessioni
client: Client
link: Link link: Link
detail: Dettaglio detail: Dettaglio
regia: Regia regia: Regia
@@ -329,6 +331,11 @@ it:
opponent: Avversario opponent: Avversario
operator: Operatore operator: Operatore
platform: Piattaforma platform: Piattaforma
client_os: Sistema
app_version: Versione app
device: Dispositivo
os_version: Versione OS
carrier: Operatore telefonico
privacy: Privacy privacy: Privacy
quality: Qualità quality: Qualità
min_quality: Qualità minima min_quality: Qualità minima
@@ -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
+8 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -427,6 +427,13 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do
t.uuid "stream_node_id" t.uuid "stream_node_id"
t.string "min_quality_preset", default: "auto", null: false t.string "min_quality_preset", default: "auto", null: false
t.boolean "audio_muted", default: false, 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 ["match_id"], name: "index_stream_sessions_on_match_id"
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true 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 t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
@@ -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
@@ -22,8 +22,8 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class E2EWizardFlowTest { class E2EWizardFlowTest {
private lateinit var device: UiDevice private lateinit var device: UiDevice
private val pkg = "com.matchlivetv.match_live_tv"
private val ctx by lazy { InstrumentationRegistry.getInstrumentation().targetContext } private val ctx by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
private val pkg by lazy { ctx.packageName }
private fun s(id: Int): String = ctx.getString(id) private fun s(id: Int): String = ctx.getString(id)
private fun su(id: Int): String = s(id).uppercase() private fun su(id: Int): String = s(id).uppercase()
@@ -20,7 +20,7 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class LocalAdaptiveBitrateUiTest { class LocalAdaptiveBitrateUiTest {
private lateinit var device: UiDevice private lateinit var device: UiDevice
private val pkg = "com.matchlivetv.match_live_tv" private val pkg by lazy { InstrumentationRegistry.getInstrumentation().targetContext.packageName }
@Before @Before
fun setUp() { fun setUp() {
@@ -47,8 +47,21 @@ class LocalAdaptiveBitrateUiTest {
tapAny("AVANTI >", "NEXT >") tapAny("AVANTI >", "NEXT >")
waitForAny(45_000, "02 · Trasmissione", "02 · Broadcast") waitForAny(45_000, "02 · Trasmissione", "02 · Broadcast")
waitForAny(30_000, "Piattaforma", "Platform") waitForAny(30_000, "Piattaforma", "Platform")
scrollDown() // Come E2EWizardFlowTest: privacy non-in-elenco e AVANTI riprovato con scroll.
tapAny("AVANTI >", "NEXT >") 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(45_000, "03 · Test rete", "03 · Network test")
waitForAny(30_000, "AVVIA TEST RETE", "START NETWORK TEST") waitForAny(30_000, "AVVIA TEST RETE", "START NETWORK TEST")
tapAny("AVVIA TEST RETE", "START NETWORK TEST") tapAny("AVVIA TEST RETE", "START NETWORK TEST")
@@ -262,4 +275,15 @@ class LocalAdaptiveBitrateUiTest {
SystemClock.sleep(300) 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 { fun login_parsesResponse() = runBlocking {
val session = container.authRepository.login( val session = container.authRepository.login(
email = "coach@matchlivetv.test", email = "coach@matchlivetv.test",
password = "password123", password = "Password123",
) )
assertEquals("coach@matchlivetv.test", session.user.email) assertEquals("coach@matchlivetv.test", session.user.email)
assertTrue(session.accessToken.isNotBlank()) assertTrue(session.accessToken.isNotBlank())
@@ -38,7 +38,7 @@ class ReleaseApiSmokeTest {
fun fetchMatches_afterLogin() = runBlocking { fun fetchMatches_afterLogin() = runBlocking {
container.authRepository.login( container.authRepository.login(
email = "coach@matchlivetv.test", email = "coach@matchlivetv.test",
password = "password123", password = "Password123",
) )
val matches = container.matchRepository.fetchMatches() val matches = container.matchRepository.fetchMatches()
assertTrue(matches.isNotEmpty()) assertTrue(matches.isNotEmpty())
@@ -48,15 +48,22 @@ class ReleaseApiSmokeTest {
fun scheduledMatch_parsesAndIsVisible() = runBlocking { fun scheduledMatch_parsesAndIsVisible() = runBlocking {
container.authRepository.login( container.authRepository.login(
email = "coach@matchlivetv.test", email = "coach@matchlivetv.test",
password = "password123", password = "Password123",
) )
val teams = container.matchRepository.fetchTeams() val teams = container.matchRepository.fetchTeams()
val tigers = teams.first { it.name == "Tigers Volley" } assertTrue(teams.isNotEmpty())
val raw = container.api.matches(tigers.id) var scheduled: com.matchlivetv.match_live_tv.data.api.MatchDto? = null
val scheduled = raw.first { it.opponentName.contains("Crazy Volley") } for (team in teams) {
assertNotNull(scheduled.scheduledAt) val found = container.api.matches(team.id).firstOrNull { !it.scheduledAt.isNullOrBlank() }
assertNotNull(parseApiInstant(scheduled.scheduledAt)) if (found != null) {
val domain = scheduled.toDomain() 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()) assertTrue(domain.isCoachHubVisible())
} }
} }
@@ -3,9 +3,13 @@ package com.matchlivetv.match_live_tv.core
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.os.BatteryManager import android.os.BatteryManager
import android.os.Build
import android.telephony.TelephonyManager
import com.matchlivetv.match_live_tv.data.api.ClientInfoPayload
data class DeviceHealthSnapshot( data class DeviceHealthSnapshot(
val batteryPercent: Int, val batteryPercent: Int,
@@ -27,6 +31,38 @@ object DeviceTelemetry {
} }
}.getOrDefault("Sconosciuto") }.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 { fun snapshot(context: Context, thermalState: ThermalState? = null): DeviceHealthSnapshot {
val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val batteryPercent = readBatteryPercent(batteryIntent) 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 { private fun readBatteryPercent(intent: Intent?): Int {
if (intent == null) return 100 if (intent == null) return 100
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
@@ -96,7 +96,7 @@ class AppContainer(context: Context) {
.filter { it.id !in dismissed } .filter { it.id !in dismissed }
} }
val sessionRepository = SessionRepository(api) val sessionRepository = SessionRepository(api, appContext)
val scoreRepository = ScoreRepository(api) val scoreRepository = ScoreRepository(api)
@@ -295,6 +295,17 @@ data class CreateSessionRequest(
@Json(name = "target_bitrate") val targetBitrate: Int = 2_500_000, @Json(name = "target_bitrate") val targetBitrate: Int = 2_500_000,
@Json(name = "target_fps") val targetFps: Int = 30, @Json(name = "target_fps") val targetFps: Int = 30,
@Json(name = "youtube_channel") val youtubeChannel: String? = null, @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( data class MinQualityRequest(
@@ -413,6 +424,7 @@ data class TelemetryRequest(
@Json(name = "target_bitrate") val targetBitrate: Int? = null, @Json(name = "target_bitrate") val targetBitrate: Int? = null,
val fps: Int? = null, val fps: Int? = null,
@Json(name = "thermal_state") val thermalState: String? = null, @Json(name = "thermal_state") val thermalState: String? = null,
val client: ClientInfoPayload? = null,
) )
data class AnnouncementDto( data class AnnouncementDto(
@@ -1,5 +1,7 @@
package com.matchlivetv.match_live_tv.data.repository 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.CreateSessionRequest
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.api.MinQualityRequest import com.matchlivetv.match_live_tv.data.api.MinQualityRequest
@@ -9,6 +11,7 @@ import com.matchlivetv.match_live_tv.domain.StreamSession
class SessionRepository( class SessionRepository(
private val api: MatchLiveApi, private val api: MatchLiveApi,
private val appContext: Context,
) { ) {
suspend fun createSession( suspend fun createSession(
matchId: String, matchId: String,
@@ -21,6 +24,7 @@ class SessionRepository(
platform = platform, platform = platform,
privacyStatus = privacyStatus, privacyStatus = privacyStatus,
youtubeChannel = youtubeChannel, youtubeChannel = youtubeChannel,
client = DeviceTelemetry.clientInfo(appContext),
), ),
).toDomain() ).toDomain()
@@ -81,6 +85,7 @@ class SessionRepository(
targetBitrate = targetBitrate, targetBitrate = targetBitrate,
fps = fps, fps = fps,
thermalState = thermalState, thermalState = thermalState,
client = DeviceTelemetry.clientInfo(appContext),
), ),
) )
} }
@@ -1,6 +1,7 @@
import Foundation import Foundation
import UIKit import UIKit
import Network import Network
import CoreTelephony
struct DeviceHealth: Sendable { struct DeviceHealth: Sendable {
let batteryPercent: Int let batteryPercent: Int
@@ -8,6 +9,16 @@ struct DeviceHealth: Sendable {
let networkType: String 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 { enum DeviceTelemetry {
static func snapshot(thermalState: ThermalState? = nil) -> DeviceHealth { static func snapshot(thermalState: ThermalState? = nil) -> DeviceHealth {
UIDevice.current.isBatteryMonitoringEnabled = true 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 { private static func currentNetworkType() -> String {
let monitor = NWPathMonitor() let monitor = NWPathMonitor()
let semaphore = DispatchSemaphore(value: 0) let semaphore = DispatchSemaphore(value: 0)
@@ -40,4 +69,27 @@ enum DeviceTelemetry {
monitor.cancel() monitor.cancel()
return result 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)
}
}
}
} }
@@ -435,6 +435,7 @@ struct CreateSessionRequest: Encodable {
let targetBitrate: Int let targetBitrate: Int
let targetFps: Int let targetFps: Int
let youtubeChannel: String? let youtubeChannel: String?
let client: ClientInfoPayload?
} }
struct AudioMuteRequest: Encodable { struct AudioMuteRequest: Encodable {
@@ -531,6 +532,7 @@ struct TelemetryRequest: Encodable {
let targetBitrate: Int? let targetBitrate: Int?
let fps: Int? let fps: Int?
let thermalState: String? let thermalState: String?
let client: ClientInfoPayload?
} }
extension ScoringRules { extension ScoringRules {
@@ -25,7 +25,8 @@ final class SessionRepository {
qualityPreset: qualityPreset, qualityPreset: qualityPreset,
targetBitrate: targetBitrate, targetBitrate: targetBitrate,
targetFps: targetFps, targetFps: targetFps,
youtubeChannel: youtubeChannel youtubeChannel: youtubeChannel,
client: DeviceTelemetry.clientInfo()
) )
).toDomain() ).toDomain()
} }
@@ -97,7 +98,8 @@ final class SessionRepository {
currentBitrate: currentBitrate, currentBitrate: currentBitrate,
targetBitrate: targetBitrate, targetBitrate: targetBitrate,
fps: fps, fps: fps,
thermalState: health.thermalState.apiValue thermalState: health.thermalState.apiValue,
client: DeviceTelemetry.clientInfo()
) )
) )
} }