diff --git a/native/android/.gitignore b/native/android/.gitignore
new file mode 100644
index 0000000..f975f94
--- /dev/null
+++ b/native/android/.gitignore
@@ -0,0 +1,8 @@
+/.gradle/
+/build/
+/local.properties
+/.idea/
+*.iml
+/captures/
+/app/build/
+.DS_Store
diff --git a/native/android/README.md b/native/android/README.md
new file mode 100644
index 0000000..9ef3060
--- /dev/null
+++ b/native/android/README.md
@@ -0,0 +1,42 @@
+# Match Live TV — app Android nativa
+
+Implementazione **da zero** in Kotlin + Jetpack Compose. Non riusa codice dalla app Flutter (`mobile/`).
+
+## Stack
+
+- UI: Jetpack Compose, Navigation
+- Rete: Retrofit + Moshi + OkHttp
+- Auth: DataStore
+- Streaming: RootEncoder (RTMP) con motore dedicato `LiveBroadcastEngine`
+
+## Struttura
+
+```
+app/src/main/kotlin/com/matchlivetv/match_live_tv/
+ core/ — config, token store
+ data/ — API, repository, DI container
+ domain/ — modelli
+ streaming/ — preview GL, motore RTMP, foreground service
+ ui/ — schermate Compose
+```
+
+## Build APK produzione
+
+```bash
+./scripts/build_native_android_apk_prod.sh
+adb install -r native/android/app/build/outputs/apk/release/app-release.apk
+```
+
+API default: `https://www.matchlivetv.it` (override con `-PAPI_BASE_URL=...`).
+
+## Roadmap
+
+- [x] Login, lista partite, avvio diretta, preview + RTMP
+- [ ] Wizard completo (rete, piattaforma YouTube, tabellone)
+- [ ] WebSocket tabellone / regia
+- [ ] Archivio registrazioni
+- [ ] **iOS nativo** — da sviluppare su Mac in `native/ios/` (SwiftUI)
+
+## iOS (prossimo passo su Mac)
+
+Il backend REST/WebSocket è condiviso. Su Mac creeremo `native/ios/` con SwiftUI e AVFoundation/RTMP equivalente, riusando gli stessi contratti API documentati in `mobile/lib/shared/api_client.dart` come riferimento funzionale (non codice).
diff --git a/native/android/app/build.gradle.kts b/native/android/app/build.gradle.kts
new file mode 100644
index 0000000..80e51c3
--- /dev/null
+++ b/native/android/app/build.gradle.kts
@@ -0,0 +1,75 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+android {
+ namespace = "com.matchlivetv.match_live_tv"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.matchlivetv.match_live_tv"
+ minSdk = 24
+ targetSdk = 35
+ versionCode = 14
+ versionName = "2.0.0-native"
+
+ val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
+ ?: "https://www.matchlivetv.it"
+ buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+
+ implementation("androidx.core:core-ktx:1.15.0")
+ implementation("androidx.activity:activity-compose:1.9.3")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+ implementation("androidx.navigation:navigation-compose:2.8.5")
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+
+ implementation("com.squareup.retrofit2:retrofit:2.11.0")
+ implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
+ implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
+ implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
+
+ implementation("androidx.datastore:datastore-preferences:1.1.1")
+
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
+
+ implementation("com.github.pedroSG94.RootEncoder:library:2.5.5")
+}
diff --git a/native/android/app/proguard-rules.pro b/native/android/app/proguard-rules.pro
new file mode 100644
index 0000000..5ca49b7
--- /dev/null
+++ b/native/android/app/proguard-rules.pro
@@ -0,0 +1,4 @@
+# RootEncoder / Pedro RTMP
+-keep class com.pedro.** { *; }
+-dontwarn com.pedro.**
+-dontwarn org.slf4j.**
diff --git a/native/android/app/src/main/AndroidManifest.xml b/native/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..bedeaaf
--- /dev/null
+++ b/native/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt
new file mode 100644
index 0000000..5562355
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MainActivity.kt
@@ -0,0 +1,29 @@
+package com.matchlivetv.match_live_tv
+
+import android.content.res.Configuration
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
+import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
+
+class MainActivity : ComponentActivity() {
+
+ private val container by lazy { (application as MatchLiveTvApplication).container }
+
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ container.broadcastCoordinator.pauseForConfigurationChange()
+ super.onConfigurationChanged(newConfig)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ MatchLiveTheme {
+ AppNavHost(container)
+ }
+ }
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MatchLiveTvApplication.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MatchLiveTvApplication.kt
new file mode 100644
index 0000000..79370b6
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/MatchLiveTvApplication.kt
@@ -0,0 +1,14 @@
+package com.matchlivetv.match_live_tv
+
+import android.app.Application
+import com.matchlivetv.match_live_tv.data.AppContainer
+
+class MatchLiveTvApplication : Application() {
+ lateinit var container: AppContainer
+ private set
+
+ override fun onCreate() {
+ super.onCreate()
+ container = AppContainer(this)
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/AppConfig.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/AppConfig.kt
new file mode 100644
index 0000000..8e1a735
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/AppConfig.kt
@@ -0,0 +1,22 @@
+package com.matchlivetv.match_live_tv.core
+
+import com.matchlivetv.match_live_tv.BuildConfig
+
+object AppConfig {
+ val apiBaseUrl: String = BuildConfig.API_BASE_URL.trimEnd('/')
+
+ val apiV1: String get() = "$apiBaseUrl/api/v1"
+
+ val cableUrl: String
+ get() {
+ val uri = java.net.URI(apiBaseUrl)
+ val wsScheme = if (uri.scheme == "https") "wss" else "ws"
+ val defaultPort = if (uri.scheme == "https") 443 else 80
+ val port = if (uri.port > 0) uri.port else defaultPort
+ return if (port == defaultPort) {
+ "$wsScheme://${uri.host}/cable"
+ } else {
+ "$wsScheme://${uri.host}:$port/cable"
+ }
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/TokenStore.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/TokenStore.kt
new file mode 100644
index 0000000..85521bb
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/TokenStore.kt
@@ -0,0 +1,67 @@
+package com.matchlivetv.match_live_tv.core
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.matchlivetv.match_live_tv.domain.User
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+private val Context.tokenDataStore: DataStore by preferencesDataStore("auth")
+
+data class StoredSession(
+ val user: User,
+ val accessToken: String,
+ val refreshToken: String,
+)
+
+class TokenStore(private val context: Context) {
+ private val keyAccess = stringPreferencesKey("access_token")
+ private val keyRefresh = stringPreferencesKey("refresh_token")
+ private val keyUserId = stringPreferencesKey("user_id")
+ private val keyUserEmail = stringPreferencesKey("user_email")
+ private val keyUserName = stringPreferencesKey("user_name")
+ private val keyUserRole = stringPreferencesKey("user_role")
+ private val keyTeamId = stringPreferencesKey("active_team_id")
+
+ val sessionFlow: Flow = context.tokenDataStore.data.map { prefs ->
+ val access = prefs[keyAccess] ?: return@map null
+ val refresh = prefs[keyRefresh] ?: return@map null
+ val id = prefs[keyUserId] ?: return@map null
+ StoredSession(
+ user = User(
+ id = id,
+ email = prefs[keyUserEmail].orEmpty(),
+ name = prefs[keyUserName].orEmpty(),
+ role = prefs[keyUserRole].orEmpty(),
+ ),
+ accessToken = access,
+ refreshToken = refresh,
+ )
+ }
+
+ val activeTeamIdFlow: Flow =
+ context.tokenDataStore.data.map { it[keyTeamId] }
+
+ suspend fun saveSession(session: StoredSession) {
+ context.tokenDataStore.edit { prefs ->
+ prefs[keyAccess] = session.accessToken
+ prefs[keyRefresh] = session.refreshToken
+ prefs[keyUserId] = session.user.id
+ prefs[keyUserEmail] = session.user.email
+ prefs[keyUserName] = session.user.name
+ prefs[keyUserRole] = session.user.role
+ }
+ }
+
+ suspend fun saveActiveTeamId(teamId: String) {
+ context.tokenDataStore.edit { it[keyTeamId] = teamId }
+ }
+
+ suspend fun clear() {
+ context.tokenDataStore.edit { it.clear() }
+ }
+}
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
new file mode 100644
index 0000000..99744ce
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt
@@ -0,0 +1,77 @@
+package com.matchlivetv.match_live_tv.data
+
+import android.content.Context
+import com.matchlivetv.match_live_tv.core.AppConfig
+import com.matchlivetv.match_live_tv.core.TokenStore
+import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
+import com.matchlivetv.match_live_tv.data.repository.AuthRepository
+import com.matchlivetv.match_live_tv.data.repository.MatchRepository
+import com.matchlivetv.match_live_tv.data.repository.SessionRepository
+import com.matchlivetv.match_live_tv.BuildConfig
+import com.matchlivetv.match_live_tv.streaming.LiveBroadcastCoordinator
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+import java.util.concurrent.TimeUnit
+
+class AppContainer(context: Context) {
+ private val appContext = context.applicationContext
+ val tokenStore = TokenStore(appContext)
+
+ @Volatile
+ private var accessToken: String? = null
+
+ private val authInterceptor = Interceptor { chain ->
+ val request = chain.request().newBuilder().apply {
+ accessToken?.let { header("Authorization", "Bearer $it") }
+ header("Accept", "application/json")
+ header("Content-Type", "application/json")
+ }.build()
+ chain.proceed(request)
+ }
+
+ private val okHttp = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(60, TimeUnit.SECONDS)
+ .writeTimeout(60, TimeUnit.SECONDS)
+ .addInterceptor(authInterceptor)
+ .apply {
+ if (BuildConfig.DEBUG) {
+ addInterceptor(
+ HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BASIC
+ },
+ )
+ }
+ }
+ .build()
+
+ private val moshi = Moshi.Builder()
+ .add(KotlinJsonAdapterFactory())
+ .build()
+
+ val api: MatchLiveApi = Retrofit.Builder()
+ .baseUrl("${AppConfig.apiV1}/")
+ .client(okHttp)
+ .addConverterFactory(MoshiConverterFactory.create(moshi))
+ .build()
+ .create(MatchLiveApi::class.java)
+
+ val authRepository = AuthRepository(api, tokenStore) { token ->
+ accessToken = token
+ }
+
+ val matchRepository = MatchRepository(api, tokenStore)
+
+ val sessionRepository = SessionRepository(api)
+
+ val broadcastCoordinator = LiveBroadcastCoordinator(appContext)
+
+ suspend fun bootstrapAuth() {
+ authRepository.currentSession()?.let { accessToken = it.accessToken }
+ }
+}
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
new file mode 100644
index 0000000..6f7ec01
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt
@@ -0,0 +1,117 @@
+package com.matchlivetv.match_live_tv.data.api
+
+import com.matchlivetv.match_live_tv.domain.AuthTokens
+import com.matchlivetv.match_live_tv.domain.Match
+import com.matchlivetv.match_live_tv.domain.StreamSession
+import com.matchlivetv.match_live_tv.domain.Team
+import com.matchlivetv.match_live_tv.domain.User
+import com.squareup.moshi.Json
+
+data class LoginRequest(val email: String, val password: String)
+
+data class RefreshRequest(@Json(name = "refresh_token") val refreshToken: String)
+
+data class LoginResponse(
+ val user: UserDto,
+ @Json(name = "access_token") val accessToken: String,
+ @Json(name = "refresh_token") val refreshToken: String,
+) {
+ fun toDomain(): Pair =
+ user.toDomain() to AuthTokens(accessToken, refreshToken)
+}
+
+data class UserDto(
+ val id: String,
+ val email: String,
+ val name: String,
+ val role: String? = null,
+) {
+ fun toDomain() = User(id, email, name, role ?: "coach")
+}
+
+data class TeamDto(
+ val id: String,
+ val name: String,
+ val sport: String? = null,
+ @Json(name = "can_stream") val canStream: Boolean? = true,
+ @Json(name = "youtube_enabled") val youtubeEnabled: Boolean? = false,
+ @Json(name = "billing_url") val billingUrl: String? = null,
+ @Json(name = "staff_manage_url") val staffManageUrl: String? = null,
+) {
+ fun toDomain() = Team(
+ id = id,
+ name = name,
+ sport = sport ?: "volleyball",
+ canStream = canStream ?: true,
+ youtubeEnabled = youtubeEnabled ?: false,
+ billingUrl = billingUrl,
+ staffManageUrl = staffManageUrl,
+ )
+}
+
+data class MatchDto(
+ val id: String,
+ @Json(name = "team_id") val teamId: String,
+ @Json(name = "team_name") val teamName: String? = null,
+ @Json(name = "opponent_name") val opponentName: String,
+ val location: String? = null,
+ @Json(name = "scheduled_at") val scheduledAt: String? = null,
+ @Json(name = "active_session_id") val activeSessionId: String? = null,
+ @Json(name = "active_session_status") val activeSessionStatus: String? = null,
+) {
+ fun toDomain() = Match(
+ id = id,
+ teamId = teamId,
+ teamName = teamName.orEmpty(),
+ opponentName = opponentName,
+ location = location,
+ scheduledAt = scheduledAt,
+ activeSessionId = activeSessionId,
+ activeSessionStatus = activeSessionStatus,
+ )
+}
+
+data class StreamSessionDto(
+ val id: String,
+ @Json(name = "match_id") val matchId: String,
+ val status: String? = null,
+ val platform: String? = null,
+ @Json(name = "rtmp_ingest_url") val rtmpIngestUrl: String? = null,
+ @Json(name = "hls_playback_url") val hlsPlaybackUrl: String? = null,
+ @Json(name = "watch_page_url") val watchPageUrl: String? = null,
+ @Json(name = "share_url") val shareUrl: String? = null,
+ @Json(name = "target_bitrate") val targetBitrate: Int? = null,
+ @Json(name = "target_fps") val targetFps: Int? = null,
+ @Json(name = "started_at") val startedAt: String? = null,
+) {
+ fun toDomain() = StreamSession(
+ id = id,
+ matchId = matchId,
+ status = status ?: "idle",
+ platform = platform ?: "matchlivetv",
+ rtmpIngestUrl = rtmpIngestUrl,
+ hlsPlaybackUrl = hlsPlaybackUrl,
+ watchPageUrl = watchPageUrl,
+ shareUrl = shareUrl,
+ targetBitrate = targetBitrate ?: 2_500_000,
+ targetFps = targetFps ?: 30,
+ startedAt = startedAt,
+ )
+}
+
+data class CreateSessionRequest(
+ val platform: String = "matchlivetv",
+ @Json(name = "privacy_status") val privacyStatus: String = "public",
+ @Json(name = "quality_preset") val qualityPreset: String = "720p_30_2.5mbps",
+ @Json(name = "target_bitrate") val targetBitrate: Int = 2_500_000,
+ @Json(name = "target_fps") val targetFps: Int = 30,
+)
+
+data class CreateMatchRequest(val match: Map)
+
+data class NetworkTestRequest(
+ @Json(name = "download_mbps") val downloadMbps: Double,
+ @Json(name = "upload_mbps") val uploadMbps: Double,
+ @Json(name = "latency_ms") val latencyMs: Int,
+ @Json(name = "network_type") val networkType: String,
+)
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt
new file mode 100644
index 0000000..44a6849
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt
@@ -0,0 +1,57 @@
+package com.matchlivetv.match_live_tv.data.api
+
+import retrofit2.http.Body
+import retrofit2.http.GET
+import retrofit2.http.PATCH
+import retrofit2.http.POST
+import retrofit2.http.Path
+
+interface MatchLiveApi {
+ @POST("auth/login")
+ suspend fun login(@Body body: LoginRequest): LoginResponse
+
+ @POST("auth/refresh")
+ suspend fun refresh(@Body body: RefreshRequest): LoginResponse
+
+ @GET("auth/me")
+ suspend fun me(): UserDto
+
+ @POST("auth/logout")
+ suspend fun logout()
+
+ @GET("teams")
+ suspend fun teams(): List
+
+ @GET("teams/{teamId}/matches")
+ suspend fun matches(@Path("teamId") teamId: String): List
+
+ @GET("matches/{matchId}")
+ suspend fun match(@Path("matchId") matchId: String): MatchDto
+
+ @POST("matches/{matchId}/sessions")
+ suspend fun createSession(
+ @Path("matchId") matchId: String,
+ @Body body: CreateSessionRequest,
+ ): StreamSessionDto
+
+ @GET("sessions/{sessionId}")
+ suspend fun session(@Path("sessionId") sessionId: String): StreamSessionDto
+
+ @PATCH("sessions/{sessionId}/start")
+ suspend fun startSession(@Path("sessionId") sessionId: String): StreamSessionDto
+
+ @PATCH("sessions/{sessionId}/stop")
+ suspend fun stopSession(@Path("sessionId") sessionId: String): StreamSessionDto
+
+ @PATCH("sessions/{sessionId}/pause")
+ suspend fun pauseSession(@Path("sessionId") sessionId: String): StreamSessionDto
+
+ @PATCH("sessions/{sessionId}/resume")
+ suspend fun resumeSession(@Path("sessionId") sessionId: String): StreamSessionDto
+
+ @POST("sessions/{sessionId}/network_test")
+ suspend fun networkTest(
+ @Path("sessionId") sessionId: String,
+ @Body body: NetworkTestRequest,
+ )
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/AuthRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/AuthRepository.kt
new file mode 100644
index 0000000..50dc413
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/AuthRepository.kt
@@ -0,0 +1,54 @@
+package com.matchlivetv.match_live_tv.data.repository
+
+import com.matchlivetv.match_live_tv.core.StoredSession
+import com.matchlivetv.match_live_tv.core.TokenStore
+import com.matchlivetv.match_live_tv.data.api.LoginRequest
+import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
+import com.matchlivetv.match_live_tv.data.api.RefreshRequest
+import kotlinx.coroutines.flow.first
+
+class AuthRepository(
+ private val api: MatchLiveApi,
+ private val tokenStore: TokenStore,
+ private val onTokenChanged: (String?) -> Unit,
+) {
+ val sessionFlow = tokenStore.sessionFlow
+
+ suspend fun login(email: String, password: String): StoredSession {
+ val response = api.login(LoginRequest(email, password))
+ val (user, tokens) = response.toDomain()
+ val session = StoredSession(user, tokens.accessToken, tokens.refreshToken)
+ tokenStore.saveSession(session)
+ onTokenChanged(tokens.accessToken)
+ return session
+ }
+
+ suspend fun currentSession(): StoredSession? = sessionFlow.first()
+
+ suspend fun validateOrRefresh(): StoredSession? {
+ val stored = currentSession() ?: return null
+ return runCatching {
+ api.me()
+ stored
+ }.getOrElse {
+ runCatching {
+ refresh(stored.refreshToken)
+ }.getOrNull()
+ }
+ }
+
+ suspend fun refresh(refreshToken: String): StoredSession {
+ val response = api.refresh(RefreshRequest(refreshToken))
+ val (user, tokens) = response.toDomain()
+ val session = StoredSession(user, tokens.accessToken, tokens.refreshToken)
+ tokenStore.saveSession(session)
+ onTokenChanged(tokens.accessToken)
+ return session
+ }
+
+ suspend fun logout() {
+ runCatching { api.logout() }
+ tokenStore.clear()
+ onTokenChanged(null)
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt
new file mode 100644
index 0000000..b86f680
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/MatchRepository.kt
@@ -0,0 +1,27 @@
+package com.matchlivetv.match_live_tv.data.repository
+
+import com.matchlivetv.match_live_tv.core.TokenStore
+import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
+import com.matchlivetv.match_live_tv.domain.Match
+import com.matchlivetv.match_live_tv.domain.Team
+import kotlinx.coroutines.flow.first
+
+class MatchRepository(
+ private val api: MatchLiveApi,
+ private val tokenStore: TokenStore,
+) {
+ suspend fun fetchTeams(): List =
+ api.teams().map { it.toDomain() }
+
+ suspend fun fetchMatches(): List {
+ val teamId = tokenStore.activeTeamIdFlow.first()
+ ?: fetchTeams().firstOrNull()?.id
+ ?: return emptyList()
+ tokenStore.saveActiveTeamId(teamId)
+ return api.matches(teamId).map { it.toDomain() }
+ }
+
+ suspend fun selectTeam(teamId: String) {
+ tokenStore.saveActiveTeamId(teamId)
+ }
+}
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
new file mode 100644
index 0000000..4d2ac82
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/repository/SessionRepository.kt
@@ -0,0 +1,41 @@
+package com.matchlivetv.match_live_tv.data.repository
+
+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.NetworkTestRequest
+import com.matchlivetv.match_live_tv.domain.StreamSession
+
+class SessionRepository(
+ private val api: MatchLiveApi,
+) {
+ suspend fun createSession(matchId: String): StreamSession =
+ api.createSession(matchId, CreateSessionRequest()).toDomain()
+
+ suspend fun fetchSession(sessionId: String): StreamSession =
+ api.session(sessionId).toDomain()
+
+ suspend fun startSession(sessionId: String): StreamSession =
+ api.startSession(sessionId).toDomain()
+
+ suspend fun stopSession(sessionId: String): StreamSession =
+ api.stopSession(sessionId).toDomain()
+
+ suspend fun pauseSession(sessionId: String): StreamSession =
+ api.pauseSession(sessionId).toDomain()
+
+ suspend fun resumeSession(sessionId: String): StreamSession =
+ api.resumeSession(sessionId).toDomain()
+
+ suspend fun submitNetworkTest(
+ sessionId: String,
+ downloadMbps: Double,
+ uploadMbps: Double,
+ latencyMs: Int,
+ networkType: String,
+ ) {
+ api.networkTest(
+ sessionId,
+ NetworkTestRequest(downloadMbps, uploadMbps, latencyMs, networkType),
+ )
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt
new file mode 100644
index 0000000..3808b82
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/Models.kt
@@ -0,0 +1,57 @@
+package com.matchlivetv.match_live_tv.domain
+
+data class User(
+ val id: String,
+ val email: String,
+ val name: String,
+ val role: String,
+)
+
+data class AuthTokens(
+ val accessToken: String,
+ val refreshToken: String,
+)
+
+data class Team(
+ val id: String,
+ val name: String,
+ val sport: String,
+ val canStream: Boolean = true,
+ val youtubeEnabled: Boolean = false,
+ val billingUrl: String? = null,
+ val staffManageUrl: String? = null,
+)
+
+data class Match(
+ val id: String,
+ val teamId: String,
+ val teamName: String,
+ val opponentName: String,
+ val location: String? = null,
+ val scheduledAt: String? = null,
+ val activeSessionId: String? = null,
+ val activeSessionStatus: String? = null,
+) {
+ val canResumeCamera: Boolean
+ get() {
+ val status = activeSessionStatus ?: return false
+ return activeSessionId != null &&
+ status in setOf("connecting", "live", "reconnecting", "paused")
+ }
+}
+
+data class StreamSession(
+ val id: String,
+ val matchId: String,
+ val status: String,
+ val platform: String = "matchlivetv",
+ val rtmpIngestUrl: String? = null,
+ val hlsPlaybackUrl: String? = null,
+ val watchPageUrl: String? = null,
+ val shareUrl: String? = null,
+ val targetBitrate: Int = 2_500_000,
+ val targetFps: Int = 30,
+ val startedAt: String? = null,
+) {
+ val isLive: Boolean get() = status == "live" || status == "reconnecting"
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt
new file mode 100644
index 0000000..be47fb9
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/BroadcastModels.kt
@@ -0,0 +1,28 @@
+package com.matchlivetv.match_live_tv.streaming
+
+data class BroadcastConfig(
+ val rtmpUrl: String,
+ val width: Int = 1280,
+ val height: Int = 720,
+ val videoBitrate: Int = 2_500_000,
+ val audioBitrate: Int = 128_000,
+ val fps: Int = 30,
+ val portrait: Boolean = false,
+)
+
+enum class BroadcastPhase {
+ IDLE,
+ PREVIEW,
+ CONNECTING,
+ LIVE,
+ RECONNECTING,
+ ERROR,
+}
+
+data class BroadcastMetrics(
+ val phase: BroadcastPhase,
+ val bitrateKbps: Long = 0,
+ val fps: Int = 0,
+ val connected: Boolean = false,
+ val lastError: String? = null,
+)
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt
new file mode 100644
index 0000000..96b1c5a
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastCoordinator.kt
@@ -0,0 +1,44 @@
+package com.matchlivetv.match_live_tv.streaming
+
+import android.content.Context
+import android.os.Build
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class LiveBroadcastCoordinator(context: Context) {
+
+ private val appContext = context.applicationContext
+ val engine = LiveBroadcastEngine(appContext)
+
+ private val _metrics = MutableStateFlow(BroadcastMetrics(BroadcastPhase.IDLE))
+ val metrics: StateFlow = _metrics.asStateFlow()
+
+ init {
+ engine.setMetricsListener { value ->
+ _metrics.value = value
+ }
+ }
+
+ fun pauseForConfigurationChange() {
+ engine.pauseForConfigurationChange()
+ }
+
+ fun startService() {
+ val intent = LiveBroadcastService.startIntent(appContext)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ appContext.startForegroundService(intent)
+ } else {
+ appContext.startService(intent)
+ }
+ }
+
+ fun stopService() {
+ appContext.startService(LiveBroadcastService.stopIntent(appContext))
+ }
+
+ fun release() {
+ stopService()
+ engine.release()
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt
new file mode 100644
index 0000000..d842fd5
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastEngine.kt
@@ -0,0 +1,305 @@
+package com.matchlivetv.match_live_tv.streaming
+
+import android.content.Context
+import android.media.MediaCodecInfo
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import android.util.Log
+import com.pedro.common.ConnectChecker
+import com.pedro.library.generic.GenericStream
+import com.pedro.library.util.SensorRotationManager
+
+/**
+ * Motore RTMP nativo Match Live TV — implementazione dedicata app Android (non Flutter).
+ */
+class LiveBroadcastEngine(
+ private val appContext: Context,
+) : ConnectChecker {
+
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private var genericStream: GenericStream? = null
+ private var previewView: LivePreviewView? = null
+ private var config: BroadcastConfig? = null
+ private var phase = BroadcastPhase.IDLE
+ private var surfaceSuspended = false
+ private var rebindPending = false
+ private var lastCameraRotation = -1
+ private var sensorManager: SensorRotationManager? = null
+ private var listener: ((BroadcastMetrics) -> Unit)? = null
+
+ private val rebindRunnable = Runnable { reattachPreviewIfNeeded() }
+
+ fun setMetricsListener(block: ((BroadcastMetrics) -> Unit)?) {
+ listener = block
+ emitMetrics()
+ }
+
+ fun bindPreview(view: LivePreviewView) {
+ mainHandler.post {
+ previewView = view
+ view.onSurfaceLost = { onPreviewSurfaceLost() }
+ updatePipelinePreservationFlag()
+ if (surfaceSuspended || rebindPending) {
+ scheduleRebind()
+ } else if (phase == BroadcastPhase.PREVIEW || phase == BroadcastPhase.LIVE) {
+ attachPreviewInternal()
+ } else {
+ ensurePreviewPrepared(view)
+ }
+ }
+ }
+
+ /** Chiamato da Activity.onConfigurationChanged prima di super. */
+ fun pauseForConfigurationChange() {
+ mainHandler.post {
+ if (phase == BroadcastPhase.IDLE) return@post
+ Log.i(TAG, "pauseForConfigurationChange phase=$phase")
+ surfaceSuspended = true
+ rebindPending = true
+ mainHandler.removeCallbacks(rebindRunnable)
+ previewView?.setForceRender(false)
+ runCatching {
+ genericStream?.getGlInterface()?.setForceRender(false)
+ genericStream?.getGlInterface()?.deAttachPreview()
+ }
+ }
+ }
+
+ fun onPreviewSurfaceReady(view: LivePreviewView) {
+ mainHandler.post {
+ previewView = view
+ view.onSurfaceLost = { onPreviewSurfaceLost() }
+ updatePipelinePreservationFlag()
+ scheduleRebind()
+ }
+ }
+
+ private fun onPreviewSurfaceLost() {
+ mainHandler.post {
+ surfaceSuspended = true
+ rebindPending = true
+ mainHandler.removeCallbacks(rebindRunnable)
+ previewView?.setForceRender(false)
+ runCatching {
+ genericStream?.getGlInterface()?.setForceRender(false)
+ genericStream?.getGlInterface()?.deAttachPreview()
+ }
+ }
+ }
+
+ fun startBroadcast(broadcastConfig: BroadcastConfig) {
+ mainHandler.post {
+ config = broadcastConfig
+ val stream = obtainStream()
+ resetEncoder(stream, broadcastConfig)
+ attachPreviewInternal()
+ applyOrientation(broadcastConfig.portrait, broadcastConfig.portrait)
+ startSensor()
+ setPhase(BroadcastPhase.CONNECTING)
+ Log.i(TAG, "publish ${broadcastConfig.rtmpUrl}")
+ stream.startStream(broadcastConfig.rtmpUrl)
+ }
+ }
+
+ fun stopBroadcast() {
+ mainHandler.post {
+ stopSensor()
+ genericStream?.let { stream ->
+ if (stream.isStreaming) stream.stopStream()
+ if (stream.isOnPreview) stream.stopPreview()
+ }
+ genericStream = null
+ surfaceSuspended = false
+ rebindPending = false
+ setPhase(BroadcastPhase.IDLE)
+ }
+ }
+
+ fun release() {
+ mainHandler.post {
+ stopBroadcast()
+ previewView = null
+ }
+ }
+
+ private fun obtainStream(): GenericStream {
+ val existing = genericStream
+ if (existing != null) return existing
+ return GenericStream(appContext, this).also { stream ->
+ stream.getGlInterface().autoHandleOrientation = false
+ stream.getStreamClient().setReTries(10)
+ genericStream = stream
+ }
+ }
+
+ private fun ensurePreviewPrepared(view: LivePreviewView) {
+ val stream = obtainStream()
+ if (stream.isOnPreview) return
+ val cfg = config ?: BroadcastConfig(rtmpUrl = "")
+ resetEncoder(stream, cfg.copy(rtmpUrl = cfg.rtmpUrl.ifBlank { "rtmp://127.0.0.1/preview" }))
+ attachPreviewInternal()
+ setPhase(BroadcastPhase.PREVIEW)
+ }
+
+ private fun resetEncoder(stream: GenericStream, cfg: BroadcastConfig) {
+ if (stream.isStreaming) stream.stopStream()
+ if (stream.isOnPreview) stream.stopPreview()
+ val rotation = if (cfg.portrait) 90 else 0
+ val ok = stream.prepareVideo(
+ width = cfg.width,
+ height = cfg.height,
+ bitrate = cfg.videoBitrate,
+ fps = cfg.fps,
+ iFrameInterval = 1,
+ rotation = rotation,
+ profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
+ level = MediaCodecInfo.CodecProfileLevel.AVCLevel31,
+ ) && stream.prepareAudio(48_000, false, cfg.audioBitrate)
+ if (!ok) {
+ setPhase(BroadcastPhase.ERROR, "prepare_failed")
+ }
+ }
+
+ private fun attachPreviewInternal() {
+ val view = previewView ?: return
+ val stream = genericStream ?: return
+ if (!isSurfaceUsable(view)) {
+ scheduleRebind()
+ return
+ }
+ val surface = view.holder.surface ?: return
+ if (!surface.isValid) {
+ scheduleRebind()
+ return
+ }
+ runCatching {
+ val gl = stream.getGlInterface()
+ gl.setForceRender(false)
+ if (stream.isOnPreview) gl.deAttachPreview()
+ gl.attachPreview(surface)
+ gl.setPreviewResolution(view.width.coerceAtLeast(2), view.height.coerceAtLeast(2))
+ if (!stream.isOnPreview) stream.startPreview(view, false)
+ gl.setForceRender(true, 30)
+ surfaceSuspended = false
+ rebindPending = false
+ }.onFailure {
+ Log.w(TAG, "attachPreviewInternal: ${it.message}")
+ scheduleRebind()
+ }
+ }
+
+ private fun scheduleRebind() {
+ mainHandler.removeCallbacks(rebindRunnable)
+ mainHandler.postDelayed(rebindRunnable, SURFACE_DEBOUNCE_MS)
+ }
+
+ private fun reattachPreviewIfNeeded() {
+ if (!rebindPending && !surfaceSuspended) return
+ if (!isSurfaceUsable(previewView)) {
+ scheduleRebind()
+ return
+ }
+ attachPreviewInternal()
+ config?.let { applyOrientation(it.portrait, it.portrait) }
+ }
+
+ private fun isSurfaceUsable(view: LivePreviewView?): Boolean {
+ view ?: return false
+ if (!view.isAttachedToWindow || view.width <= 0 || view.height <= 0) return false
+ val surface = view.holder?.surface ?: return false
+ return surface.isValid
+ }
+
+ private fun updatePipelinePreservationFlag() {
+ val keep = phase == BroadcastPhase.LIVE ||
+ phase == BroadcastPhase.CONNECTING ||
+ phase == BroadcastPhase.RECONNECTING
+ previewView?.keepPipelineOnSurfaceLoss = keep
+ }
+
+ private fun applyOrientation(isPortrait: Boolean, forcePortrait: Boolean) {
+ val stream = genericStream ?: return
+ if (surfaceSuspended) return
+ runCatching {
+ val gl = stream.getGlInterface()
+ gl.setForceRender(false)
+ gl.autoHandleOrientation = false
+ gl.setIsPortrait(isPortrait)
+ gl.setCameraOrientation(if (forcePortrait) 90 else 0)
+ gl.setPreviewRotation(0)
+ gl.setStreamRotation(0)
+ gl.setForceRender(true, 30)
+ }
+ }
+
+ private fun startSensor() {
+ if (sensorManager != null) return
+ sensorManager = SensorRotationManager(appContext, true, true) { rotation, portrait ->
+ mainHandler.post {
+ if (surfaceSuspended || phase == BroadcastPhase.IDLE) return@post
+ if (rotation == lastCameraRotation) return@post
+ lastCameraRotation = rotation
+ applyOrientation(portrait, portrait)
+ }
+ }.also { it.start() }
+ }
+
+ private fun stopSensor() {
+ sensorManager?.stop()
+ sensorManager = null
+ lastCameraRotation = -1
+ }
+
+ private fun setPhase(newPhase: BroadcastPhase, error: String? = null) {
+ phase = newPhase
+ updatePipelinePreservationFlag()
+ emitMetrics(error)
+ }
+
+ private fun emitMetrics(error: String? = null) {
+ val stream = genericStream
+ listener?.invoke(
+ BroadcastMetrics(
+ phase = phase,
+ bitrateKbps = 0,
+ fps = 0,
+ connected = phase == BroadcastPhase.LIVE,
+ lastError = error,
+ ),
+ )
+ }
+
+ override fun onConnectionStarted(url: String) {
+ mainHandler.post { setPhase(BroadcastPhase.CONNECTING) }
+ }
+
+ override fun onConnectionSuccess() {
+ mainHandler.post { setPhase(BroadcastPhase.LIVE) }
+ }
+
+ override fun onConnectionFailed(reason: String) {
+ mainHandler.post { setPhase(BroadcastPhase.ERROR, reason) }
+ }
+
+ override fun onNewBitrate(bitrate: Long) = Unit
+
+ override fun onDisconnect() {
+ mainHandler.post {
+ if (phase == BroadcastPhase.LIVE) setPhase(BroadcastPhase.RECONNECTING)
+ }
+ }
+
+ override fun onAuthError() {
+ mainHandler.post { setPhase(BroadcastPhase.ERROR, "auth_error") }
+ }
+
+ override fun onAuthSuccess() {
+ mainHandler.post { setPhase(BroadcastPhase.LIVE) }
+ }
+
+ companion object {
+ private const val TAG = "LiveBroadcastEngine"
+ private const val SURFACE_DEBOUNCE_MS = 700L
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastService.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastService.kt
new file mode 100644
index 0000000..b7c14ec
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LiveBroadcastService.kt
@@ -0,0 +1,138 @@
+package com.matchlivetv.match_live_tv.streaming
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Binder
+import android.os.Build
+import android.os.IBinder
+import android.os.PowerManager
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import com.matchlivetv.match_live_tv.MainActivity
+import com.matchlivetv.match_live_tv.R
+
+class LiveBroadcastService : Service() {
+
+ private val binder = LocalBinder()
+ private var wakeLock: PowerManager.WakeLock? = null
+
+ inner class LocalBinder : Binder() {
+ fun service(): LiveBroadcastService = this@LiveBroadcastService
+ }
+
+ override fun onBind(intent: Intent?): IBinder = binder
+
+ override fun onCreate() {
+ super.onCreate()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.streaming_notification_channel),
+ NotificationManager.IMPORTANCE_LOW,
+ )
+ getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
+ }
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_START -> promoteForeground(getString(R.string.streaming_notification_active))
+ ACTION_STOP -> stopForegroundAndSelf()
+ }
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ releaseWakeLock()
+ super.onDestroy()
+ }
+
+ fun updatePhase(phase: BroadcastPhase) {
+ val text = when (phase) {
+ BroadcastPhase.CONNECTING -> getString(R.string.streaming_notification_connecting)
+ BroadcastPhase.LIVE -> getString(R.string.streaming_notification_streaming)
+ BroadcastPhase.RECONNECTING -> getString(R.string.streaming_notification_reconnecting)
+ BroadcastPhase.ERROR -> getString(R.string.streaming_notification_error)
+ else -> getString(R.string.streaming_notification_active)
+ }
+ getSystemService(NotificationManager::class.java)
+ .notify(NOTIFICATION_ID, buildNotification(text))
+ }
+
+ private fun promoteForeground(content: String) {
+ acquireWakeLock()
+ val notification = buildNotification(content)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
+ )
+ } else {
+ startForeground(NOTIFICATION_ID, notification)
+ }
+ }
+
+ private fun stopForegroundAndSelf() {
+ releaseWakeLock()
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+
+ private fun buildNotification(content: String): Notification {
+ val launch = PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ val stop = PendingIntent.getService(
+ this,
+ 1,
+ stopIntent(this),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.streaming_notification_title))
+ .setContentText(content)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setOngoing(true)
+ .setContentIntent(launch)
+ .addAction(android.R.drawable.ic_media_pause, getString(R.string.streaming_notification_stop), stop)
+ .build()
+ }
+
+ private fun acquireWakeLock() {
+ if (wakeLock?.isHeld == true) return
+ val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
+ wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$packageName:LiveBroadcast").apply {
+ acquire(90L * 60L * 1000L)
+ }
+ }
+
+ private fun releaseWakeLock() {
+ wakeLock?.takeIf { it.isHeld }?.release()
+ wakeLock = null
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "match_live_tv_live"
+ private const val NOTIFICATION_ID = 2001
+ private const val ACTION_START = "com.matchlivetv.action.START_LIVE"
+ private const val ACTION_STOP = "com.matchlivetv.action.STOP_LIVE"
+
+ fun startIntent(context: Context) =
+ Intent(context, LiveBroadcastService::class.java).setAction(ACTION_START)
+
+ fun stopIntent(context: Context) =
+ Intent(context, LiveBroadcastService::class.java).setAction(ACTION_STOP)
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LivePreviewView.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LivePreviewView.kt
new file mode 100644
index 0000000..7dcbfc9
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/streaming/LivePreviewView.kt
@@ -0,0 +1,28 @@
+package com.matchlivetv.match_live_tv.streaming
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.SurfaceHolder
+import com.pedro.library.view.OpenGlView
+
+/**
+ * Surface camera per la preview in-app. Durante una diretta attiva non invoca stop()
+ * del pipeline quando Android ricrea la surface (rotazione).
+ */
+class LivePreviewView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+) : OpenGlView(context, attrs) {
+
+ var keepPipelineOnSurfaceLoss: Boolean = false
+ var onSurfaceLost: (() -> Unit)? = null
+
+ override fun surfaceDestroyed(holder: SurfaceHolder) {
+ if (keepPipelineOnSurfaceLoss && isRunning) {
+ setForceRender(false)
+ onSurfaceLost?.invoke()
+ return
+ }
+ super.surfaceDestroyed(holder)
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt
new file mode 100644
index 0000000..d253a1e
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/broadcast/BroadcastScreen.kt
@@ -0,0 +1,145 @@
+package com.matchlivetv.match_live_tv.ui.broadcast
+
+import android.view.ViewGroup
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import com.matchlivetv.match_live_tv.data.AppContainer
+import com.matchlivetv.match_live_tv.domain.StreamSession
+import com.matchlivetv.match_live_tv.streaming.BroadcastConfig
+import com.matchlivetv.match_live_tv.streaming.BroadcastPhase
+import com.matchlivetv.match_live_tv.streaming.LivePreviewView
+import com.matchlivetv.match_live_tv.ui.theme.MatchColors
+import kotlinx.coroutines.launch
+
+@Composable
+fun BroadcastScreen(
+ container: AppContainer,
+ sessionId: String,
+ onFinished: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ var session by remember { mutableStateOf(null) }
+ var error by remember { mutableStateOf(null) }
+ val metrics by container.broadcastCoordinator.metrics.collectAsState()
+ val configuration = LocalConfiguration.current
+ val portrait = configuration.screenHeightDp > configuration.screenWidthDp
+
+ LaunchedEffect(sessionId) {
+ runCatching { container.sessionRepository.fetchSession(sessionId) }
+ .onSuccess { loaded ->
+ session = loaded
+ val url = loaded.rtmpIngestUrl
+ if (url.isNullOrBlank()) {
+ error = "URL RTMP mancante"
+ return@onSuccess
+ }
+ container.broadcastCoordinator.startService()
+ container.broadcastCoordinator.engine.startBroadcast(
+ BroadcastConfig(
+ rtmpUrl = url,
+ width = if (portrait) 720 else 1280,
+ height = if (portrait) 1280 else 720,
+ portrait = portrait,
+ videoBitrate = loaded.targetBitrate,
+ fps = loaded.targetFps,
+ ),
+ )
+ }
+ .onFailure { error = it.message ?: "Sessione non disponibile" }
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ container.broadcastCoordinator.stopService()
+ container.broadcastCoordinator.engine.stopBroadcast()
+ }
+ }
+
+ Column(Modifier.fillMaxSize().background(MatchColors.Background)) {
+ Box(Modifier.weight(1f).fillMaxWidth()) {
+ CameraPreviewPanel(container)
+ Text(
+ text = when (metrics.phase) {
+ BroadcastPhase.LIVE -> "IN DIRETTA"
+ BroadcastPhase.CONNECTING -> "CONNESSIONE…"
+ BroadcastPhase.RECONNECTING -> "RICONNESSIONE…"
+ BroadcastPhase.ERROR -> metrics.lastError ?: "ERRORE"
+ else -> "PREVIEW"
+ },
+ color = if (metrics.phase == BroadcastPhase.LIVE) MatchColors.SuccessGreen else MatchColors.AccentYellow,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ error?.let {
+ Text(it, color = MatchColors.PrimaryRed, modifier = Modifier.padding(16.dp))
+ }
+ Button(
+ onClick = {
+ scope.launch {
+ runCatching { container.sessionRepository.stopSession(sessionId) }
+ container.broadcastCoordinator.engine.stopBroadcast()
+ container.broadcastCoordinator.stopService()
+ onFinished()
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ ) {
+ Text("TERMINA DIRETTA")
+ }
+ }
+}
+
+@Composable
+private fun CameraPreviewPanel(container: AppContainer) {
+ val context = LocalContext.current
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = {
+ LivePreviewView(context).apply {
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ holder.addCallback(object : android.view.SurfaceHolder.Callback {
+ override fun surfaceCreated(holder: android.view.SurfaceHolder) {
+ container.broadcastCoordinator.engine.onPreviewSurfaceReady(this@apply)
+ }
+
+ override fun surfaceChanged(
+ holder: android.view.SurfaceHolder,
+ format: Int,
+ width: Int,
+ height: Int,
+ ) {
+ container.broadcastCoordinator.engine.onPreviewSurfaceReady(this@apply)
+ }
+
+ override fun surfaceDestroyed(holder: android.view.SurfaceHolder) = Unit
+ })
+ container.broadcastCoordinator.engine.bindPreview(this)
+ }
+ },
+ )
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt
new file mode 100644
index 0000000..f45bb4d
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt
@@ -0,0 +1,87 @@
+package com.matchlivetv.match_live_tv.ui.login
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import com.matchlivetv.match_live_tv.data.AppContainer
+import com.matchlivetv.match_live_tv.ui.theme.MatchColors
+import kotlinx.coroutines.launch
+
+@Composable
+fun LoginScreen(
+ container: AppContainer,
+ onLoggedIn: () -> Unit,
+) {
+ var email by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var error by remember { mutableStateOf(null) }
+ var loading by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text("MATCH LIVE TV", color = MatchColors.PrimaryRed)
+ Spacer(Modifier.height(24.dp))
+ OutlinedTextField(
+ value = email,
+ onValueChange = { email = it },
+ label = { Text("Email") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ )
+ Spacer(Modifier.height(12.dp))
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("Password") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ )
+ error?.let {
+ Spacer(Modifier.height(8.dp))
+ Text(it, color = MatchColors.PrimaryRed)
+ }
+ Spacer(Modifier.height(20.dp))
+ Button(
+ onClick = {
+ loading = true
+ error = null
+ scope.launch {
+ runCatching {
+ container.authRepository.login(email.trim(), password)
+ }.onSuccess {
+ onLoggedIn()
+ }.onFailure {
+ error = it.message ?: "Login fallito"
+ }
+ loading = false
+ }
+ },
+ enabled = !loading && email.isNotBlank() && password.isNotBlank(),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(if (loading) "Accesso…" else "ACCEDI")
+ }
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt
new file mode 100644
index 0000000..8024af9
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt
@@ -0,0 +1,132 @@
+package com.matchlivetv.match_live_tv.ui.matches
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.matchlivetv.match_live_tv.data.AppContainer
+import com.matchlivetv.match_live_tv.domain.Match
+import com.matchlivetv.match_live_tv.domain.StreamSession
+import com.matchlivetv.match_live_tv.ui.theme.MatchColors
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MatchesScreen(
+ container: AppContainer,
+ onOpenBroadcast: (sessionId: String) -> Unit,
+ onLogout: () -> Unit,
+) {
+ var loading by remember { mutableStateOf(true) }
+ var error by remember { mutableStateOf(null) }
+ var matches by remember { mutableStateOf>(emptyList()) }
+ val scope = rememberCoroutineScope()
+
+ fun reload() {
+ loading = true
+ error = null
+ scope.launch {
+ runCatching { container.matchRepository.fetchMatches() }
+ .onSuccess { matches = it }
+ .onFailure { error = it.message ?: "Errore caricamento" }
+ loading = false
+ }
+ }
+
+ LaunchedEffect(Unit) { reload() }
+
+ fun startMatch(match: Match) {
+ scope.launch {
+ runCatching {
+ val session = if (match.canResumeCamera && match.activeSessionId != null) {
+ container.sessionRepository.fetchSession(match.activeSessionId)
+ } else {
+ val created = container.sessionRepository.createSession(match.id)
+ container.sessionRepository.submitNetworkTest(
+ sessionId = created.id,
+ downloadMbps = 20.0,
+ uploadMbps = 5.0,
+ latencyMs = 50,
+ networkType = "WiFi",
+ )
+ container.sessionRepository.startSession(created.id)
+ }
+ session
+ }.onSuccess { session: StreamSession ->
+ onOpenBroadcast(session.id)
+ }.onFailure {
+ error = it.message ?: "Impossibile avviare la diretta"
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("MATCH LIVE TV") },
+ actions = {
+ IconButton(onClick = {
+ scope.launch {
+ container.authRepository.logout()
+ onLogout()
+ }
+ }) {
+ Text("Esci")
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp),
+ ) {
+ when {
+ loading -> CircularProgressIndicator(color = MatchColors.PrimaryRed)
+ error != null -> {
+ Text(error!!, color = MatchColors.PrimaryRed)
+ Button(onClick = { reload() }) { Text("Riprova") }
+ }
+ matches.isEmpty() -> Text("Nessuna partita in programma")
+ else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(matches, key = { it.id }) { match ->
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .clickable { startMatch(match) }
+ .padding(12.dp),
+ ) {
+ Text("${match.teamName} vs ${match.opponentName}")
+ Text(
+ if (match.canResumeCamera) "Riprendi diretta" else "Vai in diretta",
+ color = MatchColors.AccentYellow,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt
new file mode 100644
index 0000000..ec9b87a
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/AppNavHost.kt
@@ -0,0 +1,69 @@
+package com.matchlivetv.match_live_tv.ui.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import com.matchlivetv.match_live_tv.data.AppContainer
+import com.matchlivetv.match_live_tv.ui.broadcast.BroadcastScreen
+import com.matchlivetv.match_live_tv.ui.login.LoginScreen
+import com.matchlivetv.match_live_tv.ui.matches.MatchesScreen
+import com.matchlivetv.match_live_tv.ui.splash.SplashScreen
+
+@Composable
+fun AppNavHost(container: AppContainer) {
+ val navController = rememberNavController()
+
+ NavHost(navController = navController, startDestination = Routes.Splash) {
+ composable(Routes.Splash) {
+ SplashScreen(
+ container = container,
+ onAuthenticated = {
+ navController.navigate(Routes.Matches) {
+ popUpTo(Routes.Splash) { inclusive = true }
+ }
+ },
+ onUnauthenticated = {
+ navController.navigate(Routes.Login) {
+ popUpTo(Routes.Splash) { inclusive = true }
+ }
+ },
+ )
+ }
+ composable(Routes.Login) {
+ LoginScreen(container) {
+ navController.navigate(Routes.Matches) {
+ popUpTo(Routes.Login) { inclusive = true }
+ }
+ }
+ }
+ composable(Routes.Matches) {
+ MatchesScreen(
+ container = container,
+ onOpenBroadcast = { sessionId ->
+ navController.navigate(Routes.broadcast(sessionId))
+ },
+ onLogout = {
+ navController.navigate(Routes.Login) {
+ popUpTo(Routes.Matches) { inclusive = true }
+ }
+ },
+ )
+ }
+ composable(
+ route = Routes.Broadcast,
+ arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
+ ) { entry ->
+ val sessionId = entry.arguments?.getString("sessionId") ?: return@composable
+ BroadcastScreen(
+ container = container,
+ sessionId = sessionId,
+ onFinished = {
+ navController.popBackStack()
+ },
+ )
+ }
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt
new file mode 100644
index 0000000..6c03074
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/navigation/Routes.kt
@@ -0,0 +1,10 @@
+package com.matchlivetv.match_live_tv.ui.navigation
+
+object Routes {
+ const val Splash = "splash"
+ const val Login = "login"
+ const val Matches = "matches"
+ const val Broadcast = "broadcast/{sessionId}"
+
+ fun broadcast(sessionId: String) = "broadcast/$sessionId"
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/splash/SplashScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/splash/SplashScreen.kt
new file mode 100644
index 0000000..cf245c9
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/splash/SplashScreen.kt
@@ -0,0 +1,30 @@
+package com.matchlivetv.match_live_tv.ui.splash
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.matchlivetv.match_live_tv.data.AppContainer
+import com.matchlivetv.match_live_tv.ui.theme.MatchColors
+
+@Composable
+fun SplashScreen(
+ container: AppContainer,
+ onAuthenticated: () -> Unit,
+ onUnauthenticated: () -> Unit,
+) {
+ LaunchedEffect(Unit) {
+ container.bootstrapAuth()
+ val session = container.authRepository.validateOrRefresh()
+ if (session != null) onAuthenticated() else onUnauthenticated()
+ }
+
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator(color = MatchColors.PrimaryRed)
+ Text("MATCH LIVE TV", modifier = Modifier.align(Alignment.BottomCenter))
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/theme/AppTheme.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/theme/AppTheme.kt
new file mode 100644
index 0000000..c6c7373
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/theme/AppTheme.kt
@@ -0,0 +1,58 @@
+package com.matchlivetv.match_live_tv.ui.theme
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import androidx.compose.material3.Typography
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.MaterialTheme
+
+object MatchColors {
+ val PrimaryRed = Color(0xFFFF2D2D)
+ val Background = Color(0xFF0A0A0A)
+ val Surface = Color(0xFF1E1E1E)
+ val SurfaceElevated = Color(0xFF2A2A2A)
+ val AccentYellow = Color(0xFFF5C518)
+ val SuccessGreen = Color(0xFF22C55E)
+ val TextSecondary = Color(0xFF9CA3AF)
+}
+
+private val DarkScheme = darkColorScheme(
+ primary = MatchColors.PrimaryRed,
+ onPrimary = Color.White,
+ background = MatchColors.Background,
+ onBackground = Color.White,
+ surface = MatchColors.Surface,
+ onSurface = Color.White,
+ secondary = MatchColors.AccentYellow,
+)
+
+private val Typography = Typography(
+ headlineMedium = TextStyle(
+ fontFamily = FontFamily.SansSerif,
+ fontWeight = FontWeight.Bold,
+ fontSize = 24.sp,
+ ),
+ titleMedium = TextStyle(
+ fontFamily = FontFamily.SansSerif,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 18.sp,
+ ),
+ bodyMedium = TextStyle(
+ fontFamily = FontFamily.SansSerif,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ color = MatchColors.TextSecondary,
+ ),
+)
+
+@androidx.compose.runtime.Composable
+fun MatchLiveTheme(content: @androidx.compose.runtime.Composable () -> Unit) {
+ MaterialTheme(
+ colorScheme = DarkScheme,
+ typography = Typography,
+ content = content,
+ )
+}
diff --git a/native/android/app/src/main/res/drawable/ic_launcher.xml b/native/android/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..515461b
--- /dev/null
+++ b/native/android/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/native/android/app/src/main/res/drawable/ic_notification.xml b/native/android/app/src/main/res/drawable/ic_notification.xml
new file mode 100644
index 0000000..331e4c0
--- /dev/null
+++ b/native/android/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/native/android/app/src/main/res/values/strings.xml b/native/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..cd869a6
--- /dev/null
+++ b/native/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,13 @@
+
+
+ Match Live TV
+ Diretta in corso
+ Notifica durante lo streaming live
+ Match Live TV
+ Preparazione diretta…
+ Connessione RTMP…
+ In diretta
+ Riconnessione…
+ Errore streaming
+ Termina
+
diff --git a/native/android/app/src/main/res/values/themes.xml b/native/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..2e23a26
--- /dev/null
+++ b/native/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/native/android/app/src/main/res/xml/network_security_config.xml b/native/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..d7b4192
--- /dev/null
+++ b/native/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/native/android/build.gradle.kts b/native/android/build.gradle.kts
new file mode 100644
index 0000000..cfd711d
--- /dev/null
+++ b/native/android/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ id("com.android.application") version "8.7.3" apply false
+ id("org.jetbrains.kotlin.android") version "2.1.0" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
+}
diff --git a/native/android/gradle.properties b/native/android/gradle.properties
new file mode 100644
index 0000000..f97184b
--- /dev/null
+++ b/native/android/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/native/android/gradle/wrapper/gradle-wrapper.jar b/native/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..13372ae
Binary files /dev/null and b/native/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/native/android/gradle/wrapper/gradle-wrapper.properties b/native/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..2d428bf
--- /dev/null
+++ b/native/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
diff --git a/native/android/gradlew b/native/android/gradlew
new file mode 100755
index 0000000..9d82f78
--- /dev/null
+++ b/native/android/gradlew
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn ( ) {
+ echo "$*"
+}
+
+die ( ) {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+esac
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
+function splitJvmOpts() {
+ JVM_OPTS=("$@")
+}
+eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
+JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
+
+exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
diff --git a/native/android/gradlew.bat b/native/android/gradlew.bat
new file mode 100644
index 0000000..aec9973
--- /dev/null
+++ b/native/android/gradlew.bat
@@ -0,0 +1,90 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windowz variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+if "%@eval[2+2]" == "4" goto 4NT_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+goto execute
+
+:4NT_args
+@rem Get arguments from the 4NT Shell from JP Software
+set CMD_LINE_ARGS=%$
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/native/android/settings.gradle.kts b/native/android/settings.gradle.kts
new file mode 100644
index 0000000..7835376
--- /dev/null
+++ b/native/android/settings.gradle.kts
@@ -0,0 +1,19 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven { url = uri("https://jitpack.io") }
+ }
+}
+
+rootProject.name = "MatchLiveTvNative"
+include(":app")
diff --git a/scripts/build_native_android_apk_prod.sh b/scripts/build_native_android_apk_prod.sh
new file mode 100755
index 0000000..6d10aa3
--- /dev/null
+++ b/scripts/build_native_android_apk_prod.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+ANDROID_DIR="$ROOT/native/android"
+API_BASE_URL="${API_BASE_URL:-https://www.matchlivetv.it}"
+
+cd "$ANDROID_DIR"
+chmod +x gradlew
+./gradlew assembleRelease -PAPI_BASE_URL="$API_BASE_URL"
+
+APK="$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk"
+echo "APK: $APK"
+echo "API_BASE_URL=$API_BASE_URL"