Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ plugins {
}

// The release workflow reads this line: "x.y.z-beta.n" is published from the beta branch, "x.y.z" from main.
val appVersion = "0.1.1-beta.1"
val appVersion = "0.2.0-beta.1"

// The version code follows from the name, so each release installs over the one before, and a beta sits under the
// stable release that ends it: 0.3.0-beta.2 is 30002 and 0.3.0 is 30099.
Expand Down Expand Up @@ -81,6 +81,7 @@ dependencies {
implementation(project(":feature:collections"))
implementation(project(":feature:detail"))
implementation(project(":feature:player"))
implementation(project(":feature:update"))
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.datasource.okhttp)
Expand Down
32 changes: 31 additions & 1 deletion app/src/main/java/video/typetype/tv/AppContainer.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
package video.typetype.tv

import android.content.Context
import android.content.pm.ApplicationInfo
import androidx.core.content.pm.PackageInfoCompat
import androidx.media3.common.util.UnstableApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import video.typetype.tv.data.ContentRepository
import video.typetype.tv.data.PresenceReport
import video.typetype.tv.data.device.PerformanceSettings
import video.typetype.tv.data.device.PreferencesDeviceStore
import video.typetype.tv.data.device.UpdateSettings
import video.typetype.tv.data.dto.UserProfileDto
import video.typetype.tv.data.net.AndroidConnectivity
import video.typetype.tv.data.net.Connectivity
import video.typetype.tv.data.net.HttpClients
import video.typetype.tv.data.net.LazySecretStore
import video.typetype.tv.data.net.TinkSecretStore
import video.typetype.tv.data.remote.AndroidTextProvider
Expand All @@ -20,12 +27,18 @@ import video.typetype.tv.data.session.Backend
import video.typetype.tv.data.session.DeviceProfileStore
import video.typetype.tv.data.session.SessionManager
import video.typetype.tv.data.session.StreamAccess
import video.typetype.tv.data.update.ReleaseFeed
import video.typetype.tv.data.update.UpdateDownloader
import video.typetype.tv.playback.CodecSupport
import video.typetype.tv.playback.DecoderFailures
import video.typetype.tv.playback.DeviceCodecSupport
import video.typetype.tv.playback.LearnedCodecSupport
import video.typetype.tv.playback.PlaybackProvider
import video.typetype.tv.playback.RoutingPlaybackProvider
import video.typetype.tv.ui.update.InstalledApp
import video.typetype.tv.ui.update.SessionUpdateInstaller
import video.typetype.tv.ui.update.UpdateController
import java.io.File

class AppContainer(context: Context) {
private val appContext = context.applicationContext
Expand All @@ -39,6 +52,7 @@ class AppContainer(context: Context) {

private val deviceProfile by lazy { DeviceProfileStore(appContext, appVersion) }
val performance by lazy { PerformanceSettings(PreferencesDeviceStore(appContext)) }
val updateSettings by lazy { UpdateSettings(PreferencesDeviceStore(appContext, PreferencesDeviceStore.UPDATES)) }

fun warmUp() {
Thread({ secrets.warmUp() }, "secrets-warmup").start()
Expand Down Expand Up @@ -77,7 +91,7 @@ class AppContainer(context: Context) {
watch = backend.watch,
userSettings = userSettings,
access = access,
performance = performance,
deviceSections = listOf(performance, updateSettings),
)

private val codecs: CodecSupport by lazy { deviceCodecs() }
Expand All @@ -91,6 +105,22 @@ class AppContainer(context: Context) {
@androidx.annotation.OptIn(UnstableApi::class)
private fun deviceCodecs(): CodecSupport = LearnedCodecSupport(DeviceCodecSupport(appContext), decoderFailures)

val updates by lazy {
val info = appContext.packageManager.getPackageInfo(appContext.packageName, 0)
val http = HttpClients.updates()
val feed = ReleaseFeed(http)
UpdateController(
releases = feed,
download = UpdateDownloader(http),
installer = SessionUpdateInstaller(appContext),
settings = updateSettings,
installed = InstalledApp(appContext.packageName, info.versionName.orEmpty(), PackageInfoCompat.getLongVersionCode(info)),
apkFile = File(appContext.cacheDir, "update/TypeType-TV.apk"),
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
automatic = appContext.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE == 0,
)
}

val decoderFailures by lazy { DecoderFailures(PreferencesDeviceStore(appContext, PreferencesDeviceStore.DECODERS)) }
}

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/video/typetype/tv/ui/AppEntry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import kotlinx.coroutines.launch
import video.typetype.tv.AppContainer
import video.typetype.tv.data.device.UpdateSettings
import video.typetype.tv.data.session.SessionState
import video.typetype.tv.ui.auth.LoginScreen
import video.typetype.tv.ui.auth.ReconnectScreen
Expand Down Expand Up @@ -144,10 +145,12 @@ private fun AppState(container: AppContainer, state: SessionState, scope: Corout
"forget-certificate" -> session.forgetCertificate()
"mark-notifications-read" -> tryLoad({}) { repository.markNotificationsRead() }
"forget-decoders" -> container.decoderFailures.forget()
UpdateSettings.CHECK_NOW -> container.updates.checkNow()
}
}
},
performance = performance,
updates = container.updates,
)
}
}
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/video/typetype/tv/ui/AppRoot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ import video.typetype.tv.navigation.topTab
import video.typetype.tv.theme.Dimens
import video.typetype.tv.theme.Palette
import video.typetype.tv.ui.components.TopNav
import video.typetype.tv.ui.update.UpdateController
import video.typetype.tv.ui.update.UpdateHost
import video.typetype.tv.ui.update.offer
import androidx.compose.runtime.collectAsState
import video.typetype.tv.ui.home.HomeScreen
import video.typetype.tv.ui.channel.ChannelScreen
import video.typetype.tv.ui.playlist.CollectionKind
Expand Down Expand Up @@ -85,8 +89,10 @@ fun AppRoot(
account: AccountBadge,
onSettingsAction: (String) -> Unit,
performance: Performance = Performance(),
updates: UpdateController,
) {
val navigator = remember { MockNavigator() }
val updateState by updates.state.collectAsState()
val homeSession = remember { HomeSession() }
val searchSession = remember { SearchSession() }
val librarySession = remember { LibrarySession() }
Expand Down Expand Up @@ -281,6 +287,8 @@ fun AppRoot(
contentEntry = homeSession.heroEntry.takeIf { activeTab == TopTab.Home },
navEntry = navEntry,
onNavFocusChange = { navFocused = it },
updateVersion = updateState.offer?.target?.version?.toString(),
onUpdate = updates::openPanel,
)
}
}
Expand All @@ -296,6 +304,7 @@ fun AppRoot(
},
)
}
UpdateHost(updates)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package video.typetype.tv.data.device

import video.typetype.tv.data.SettingsGroup
import video.typetype.tv.data.remote.TextProvider

// A settings section kept on this box, shown among the account's sections.
interface DeviceSection {
fun addTo(groups: List<SettingsGroup>, texts: TextProvider): List<SettingsGroup>

// Returns false for a setting this section does not keep.
fun update(id: String, value: String): Boolean
}

internal fun List<SettingsGroup>.insertBefore(groupId: String, group: SettingsGroup): List<SettingsGroup> {
val at = indexOfFirst { it.id == groupId }.takeIf { it >= 0 } ?: size
return take(at) + group + drop(at)
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ class PreferencesDeviceStore(context: Context, name: String = PERFORMANCE) : Dev
companion object {
const val PERFORMANCE = "performance"
const val DECODERS = "decoders"
const val UPDATES = "updates"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,15 @@ data class Performance(

// What a slow box can turn off to stay smooth. The choice belongs to the box, not to the account: a strong
// television and a small stick signed in to the same account each keep their own.
class PerformanceSettings(private val store: DeviceStore) {
class PerformanceSettings(private val store: DeviceStore) : DeviceSection {
private val state = MutableStateFlow(read())
val current: StateFlow<Performance> = state.asStateFlow()

// The section sits with the everyday ones, before the account and the server.
fun addTo(groups: List<SettingsGroup>, texts: TextProvider): List<SettingsGroup> {
val at = groups.indexOfFirst { it.id == BEFORE_GROUP }.takeIf { it >= 0 } ?: groups.size
return groups.take(at) + group(texts) + groups.drop(at)
}
override fun addTo(groups: List<SettingsGroup>, texts: TextProvider): List<SettingsGroup> =
groups.insertBefore(BEFORE_GROUP, group(texts))

// Returns false for a setting kept on the server.
fun update(id: String, value: String): Boolean {
override fun update(id: String, value: String): Boolean {
when (id) {
in FLAGS -> store.putFlag(id, value.toBoolean())
INTRO -> store.putText(id, Intro.entries.firstOrNull { it.wire == value }?.wire ?: return false)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package video.typetype.tv.data.device

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import video.typetype.tv.data.SettingItem
import video.typetype.tv.data.SettingsGroup
import video.typetype.tv.data.remote.TextProvider
import video.typetype.tv.data.update.UpdateChannel
import video.typetype.tv.resources.R

data class UpdatePreferences(
val channel: UpdateChannel = UpdateChannel.Stable,
val checkOnStart: Boolean = true,
)

// How this box follows new versions of the app. The choice belongs to the box: a test box can follow the betas
// while the living-room TV stays on stable releases.
class UpdateSettings(private val store: DeviceStore) : DeviceSection {
private val state = MutableStateFlow(read())
val current: StateFlow<UpdatePreferences> = state.asStateFlow()

override fun addTo(groups: List<SettingsGroup>, texts: TextProvider): List<SettingsGroup> =
groups.insertBefore(BEFORE_GROUP, group(texts))

override fun update(id: String, value: String): Boolean {
when (id) {
CHECK_ON_START -> store.putFlag(id, value.toBoolean())
CHANNEL -> store.putText(id, UpdateChannel.entries.firstOrNull { it.wire == value }?.wire ?: return false)
else -> return false
}
state.value = read()
return true
}

private fun group(texts: TextProvider): SettingsGroup {
val now = state.value
return SettingsGroup(
id = GROUP,
titleRes = R.string.settings_group_updates,
items = listOf(
SettingItem.Action(CHECK_NOW, R.string.settings_check_updates, R.string.settings_check_updates_summary),
SettingItem.Choice(
id = CHANNEL,
titleRes = R.string.settings_update_channel,
options = listOf(R.string.settings_update_channel_stable, R.string.settings_update_channel_beta).map(texts::text),
defaultIndex = now.channel.ordinal,
wireValues = UpdateChannel.entries.map { it.wire },
summaryRes = R.string.settings_update_channel_summary,
),
SettingItem.Toggle(CHECK_ON_START, R.string.settings_update_on_start, R.string.settings_update_on_start_summary, now.checkOnStart),
),
)
}

private fun read() = UpdatePreferences(
channel = store.text(CHANNEL, UpdateChannel.Stable.wire).let { wire ->
UpdateChannel.entries.firstOrNull { it.wire == wire } ?: UpdateChannel.Stable
},
checkOnStart = store.flag(CHECK_ON_START, true),
)

companion object {
const val CHECK_NOW = "check-updates"
private const val GROUP = "updates"
private const val BEFORE_GROUP = "about"
private const val CHANNEL = "update-channel"
private const val CHECK_ON_START = "update-on-start"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ object HttpClients {
.addInterceptor(UserAgentInterceptor(USER_AGENT))
.build()

// Release checks and downloads go to GitHub, never to the instance, so they carry no account and no pin.
fun updates(): OkHttpClient = base(PinTrust())
.addInterceptor(UserAgentInterceptor(USER_AGENT))
.build()

fun subtitles(): OkHttpClient = base(PinTrust())
.addInterceptor(UserAgentInterceptor(USER_AGENT))
.build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import video.typetype.tv.data.dto.BlockChannelRequest
import video.typetype.tv.data.dto.BlockVideoRequest
import video.typetype.tv.data.dto.StreamDto
import video.typetype.tv.data.net.ApiException
import video.typetype.tv.data.device.PerformanceSettings
import video.typetype.tv.data.device.DeviceSection
import video.typetype.tv.data.net.ServerWatch
import video.typetype.tv.data.session.StreamAccess

Expand All @@ -49,7 +49,7 @@ class RemoteContentRepository(
private val watch: ServerWatch,
private val userSettings: RemoteUserSettings,
access: StreamAccess,
private val performance: PerformanceSettings,
private val deviceSections: List<DeviceSection>,
private val clock: () -> Long = System::currentTimeMillis,
) : ContentRepository {
private val streams = StreamStore({ url -> access.anonymousFirst { api.streamFor(url, it) } }, clock)
Expand Down Expand Up @@ -133,7 +133,8 @@ class RemoteContentRepository(
.distinct()
.take(IDEA_LIMIT)

override suspend fun settings(): List<SettingsGroup> = performance.addTo(watch.watch { settingsSource.groups(api) }, texts)
override suspend fun settings(): List<SettingsGroup> =
deviceSections.fold(watch.watch { settingsSource.groups(api) }) { groups, section -> section.addTo(groups, texts) }

override suspend fun startupPreferences(): StartupPreferences = userSettings.startupPreferences()

Expand Down Expand Up @@ -174,7 +175,7 @@ class RemoteContentRepository(
}

override suspend fun updateSetting(id: String, value: String) {
if (performance.update(id, value)) return
if (deviceSections.any { it.update(id, value) }) return
settingsSource.update(api, id, value)
userSettings.invalidate()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package video.typetype.tv.data.update

// A version as the build and the release workflow write it: "x.y.z", or "x.y.z-beta.n" for a beta, which comes before
// the stable release of the same number.
data class AppVersion(val major: Int, val minor: Int, val patch: Int, val beta: Int? = null) : Comparable<AppVersion> {
val isBeta: Boolean get() = beta != null

override fun compareTo(other: AppVersion): Int =
compareValuesBy(this, other, { it.major }, { it.minor }, { it.patch }, { it.beta ?: Int.MAX_VALUE })

override fun toString(): String = "$major.$minor.$patch" + (beta?.let { "-beta.$it" } ?: "")

companion object {
private val PATTERN = Regex("""v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?""")

fun parse(text: String): AppVersion? {
val (major, minor, patch, beta) = PATTERN.matchEntire(text.trim())?.destructured ?: return null
return AppVersion(
major.toIntOrNull() ?: return null,
minor.toIntOrNull() ?: return null,
patch.toIntOrNull() ?: return null,
if (beta.isEmpty()) null else beta.toIntOrNull() ?: return null,
)
}
}
}
Loading
Loading