Compare commits
2
Commits
873e0ea55c
...
35dfa923e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35dfa923e3 | ||
|
|
9d8b35c06c |
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
<tr>
|
||||
<th><%= t("admin.dashboard.sessions.table.match") %></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.start") %></th>
|
||||
<th><%= t("admin.dashboard.sessions.table.link") %></th>
|
||||
@@ -118,6 +119,7 @@
|
||||
<tr>
|
||||
<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 class="muted"><%= admin_session_client_summary(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>
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
<th><%= t("admin.sessions.index.table.ended") %></th>
|
||||
<th><%= t("admin.sessions.index.table.duration") %></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.link") %></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"><%= admin_session_duration_label(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>
|
||||
<div class="admin-link-compact">
|
||||
|
||||
@@ -67,6 +67,38 @@
|
||||
<dt><%= t("admin.sessions.show.fields.platform") %></dt>
|
||||
<dd><%= @session.platform %></dd>
|
||||
</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>
|
||||
<dt><%= t("admin.sessions.show.fields.privacy") %></dt>
|
||||
<dd><%= @session.privacy_status %></dd>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Generated
+8
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -22,8 +22,8 @@ import org.junit.runner.RunWith
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class E2EWizardFlowTest {
|
||||
private lateinit var device: UiDevice
|
||||
private val pkg = "com.matchlivetv.match_live_tv"
|
||||
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 su(id: Int): String = s(id).uppercase()
|
||||
|
||||
+27
-3
@@ -20,7 +20,7 @@ import org.junit.runner.RunWith
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LocalAdaptiveBitrateUiTest {
|
||||
private lateinit var device: UiDevice
|
||||
private val pkg = "com.matchlivetv.match_live_tv"
|
||||
private val pkg by lazy { InstrumentationRegistry.getInstrumentation().targetContext.packageName }
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-9
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+43
@@ -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)
|
||||
|
||||
+1
-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(
|
||||
|
||||
+5
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user