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
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# The TV app had no CI at all — only a release workflow — so a broken string
# resource reached a device before anyone saw it. This is the cheap half of a
# build: it runs in seconds and catches the failure that is invisible in review.
name: CI

on:
push:
branches: [master]
pull_request:

jobs:
strings:
name: Translated strings
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
# A mismatched format specifier throws at getString time, on that locale
# only, on the screen that uses it. Nothing else in the build sees it.
- name: Check the string resources
run: python3 tools/check_strings.py

build:
name: Assemble debug
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: gradle/actions/setup-gradle@v4
# `mergeDebugResources` is where a duplicate or badly named string
# resource fails, and it fails for every locale at once.
- name: Build
run: ./gradlew :app:assembleDebug --no-daemon
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:requestRawExternalStorageAccess="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ class SeriesPageAdapter(
binding.country.text = data.episode.toString()
root.setOnClickListener { onItemClicked?.invoke(data, absoluteAdapterPosition) }
if (LocalData.isAnimeEnabled) {
topContainer.text = "Episode ${data.episode ?: 0}"
topContainer.text = topContainer.context
.getString(R.string.episode_number, data.episode ?: 0)

} else {
topContainer.text = data.title
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ class SourceAdapter(
fun bind(item: ListItem.Header) {
binding.tvGroupCategory.text = item.category
binding.tvGroupTitle.text = item.title
binding.tvSourceCount.text = "${item.count} sources"
binding.tvSourceCount.text = binding.root.resources
.getQuantityString(R.plurals.source_count, item.count, item.count)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,12 @@ class TvToggleRowView @JvmOverloads constructor(
if (enabled) {
statusDot.background =
ContextCompat.getDrawable(context, R.drawable.netflix_status_dot_enabled)
statusText.text = "Enabled"
statusText.text = context.getString(R.string.status_enabled)
statusText.setTextColor(ContextCompat.getColor(context, R.color.netflix_green))
} else {
statusDot.background =
ContextCompat.getDrawable(context, R.drawable.netflix_status_dot_disabled)
statusText.text = "Disabled"
statusText.text = context.getString(R.string.status_disabled)
statusText.setTextColor(ContextCompat.getColor(context, R.color.netflix_gray))
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.ContextCompat
import com.bumptech.glide.Glide
import com.saikou.sozo_tv.R
import com.saikou.sozo_tv.components.spoiler.SpoilerPlugin
import com.saikou.sozo_tv.databinding.ActivityUpdateBinding
import com.saikou.sozo_tv.domain.model.AppUpdate
Expand Down Expand Up @@ -97,8 +98,8 @@ class UpdateActivity : AppCompatActivity() {
binding.progressView1.gone()
setProgressUi(0)

binding.bottomSheerCustomTitle.text = "Update Available"
binding.updateTxt.text = "Update Now"
binding.bottomSheerCustomTitle.text = getString(R.string.update_available)
binding.updateTxt.text = getString(R.string.update_now)
binding.updateBtn.isEnabled = true
binding.updateBtn.visible()

Expand Down Expand Up @@ -166,7 +167,7 @@ class UpdateActivity : AppCompatActivity() {
val link = appLink ?: return
binding.progressView1.visible()
binding.updateBtn.isEnabled = false
binding.updateTxt.text = "Downloading…"
binding.updateTxt.text = getString(R.string.update_downloading)
vm.startDownload(this, link)
}

Expand Down Expand Up @@ -255,26 +256,26 @@ class UpdateActivity : AppCompatActivity() {
when (st) {
is UpdateViewModel.UiState.Idle -> {
binding.progressView1.gone()
binding.bottomSheerCustomTitle.text = "Update Available"
binding.bottomSheerCustomTitle.text = getString(R.string.update_available)
binding.updateBtn.isEnabled = true
binding.updateTxt.text = "Update Now"
binding.updateTxt.text = getString(R.string.update_now)
binding.updateBtn.requestInitialFocus()
binding.updateBtn.visible()
}

is UpdateViewModel.UiState.Downloading -> {
binding.progressView1.visible()
binding.updateBtn.isEnabled = false
binding.updateTxt.text = "Downloading…"
binding.updateTxt.text = getString(R.string.update_downloading)
setProgressUi(st.progress1000)
}

is UpdateViewModel.UiState.DownloadComplete -> {
binding.progressView1.gone()
binding.bottomSheerCustomTitle.text = "Download Complete!"
binding.bottomSheerCustomTitle.text = getString(R.string.update_download_complete)
binding.updateBtn.visible()
binding.updateBtn.isEnabled = true
binding.updateTxt.text = "Install Now"
binding.updateTxt.text = getString(R.string.update_install_now)
binding.updateBtn.requestInitialFocus()

if (!canInstallUnknownApps()) {
Expand All @@ -287,10 +288,10 @@ class UpdateActivity : AppCompatActivity() {

is UpdateViewModel.UiState.DownloadFailed -> {
binding.progressView1.gone()
binding.bottomSheerCustomTitle.text = "Download Failed"
binding.bottomSheerCustomTitle.text = getString(R.string.update_download_failed)
binding.updateBtn.visible()
binding.updateBtn.isEnabled = true
binding.updateTxt.text = "Try Again"
binding.updateTxt.text = getString(R.string.update_try_again)
binding.updateBtn.requestInitialFocus()
snackString("Download failed: ${st.error}")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,9 @@ class CategoriesScreen : Fragment() {
super.onViewCreated(view, savedInstanceState)
val preference = PreferenceManager()
if (preference.isModeAnimeEnabled()) {
binding.textView6.text = "Filter Anime"
binding.textView6.text = getString(R.string.filter_anime)
} else {
binding.textView6.text = "Filter Movie"
binding.textView6.text = getString(R.string.filter_movie)
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,8 @@ class CastDetailAdapter(

if (item.age.isNotEmpty()) {
binding.ageBadge.visible()
binding.characterAge.text = "Age: ${item.age}"
binding.characterAge.text = binding.root.context
.getString(R.string.character_age, item.age)
binding.ageBadge.animate()
.scaleX(1.05f)
.scaleY(1.05f)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ class MovieDetailsAdapter(
}

override fun onFinish() {
countDownText.text = "Aired!"
countDownText.text = countDownText.context.getString(R.string.countdown_aired)
}
}.also { it.start() }
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ class EpisodeScreen : Fragment() {
binding.topContainer.gone()
binding.tabRv.gone()
binding.loadingLayout.visible()
binding.loadingText.text = "Media is loading.."
binding.loadingText.text = getString(R.string.media_is_loading)
}

is Resource.Success -> {
Expand Down Expand Up @@ -200,7 +200,7 @@ class EpisodeScreen : Fragment() {

Resource.Loading -> {
binding.placeHolder.root.gone()
binding.loadingText.text = "Episodes are loading.."
binding.loadingText.text = getString(R.string.episodes_are_loading)
// Only take the screen over while there is nothing to show. Hiding a populated
// grid on every page switch threw D-pad focus back out of the list.
if (adapter.itemCount == 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ import com.saikou.sozo_tv.utils.loadImage
import com.saikou.sozo_tv.utils.visible
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.activityViewModel
import com.saikou.sozo_tv.components.TvOptionChipView
import androidx.core.os.LocaleListCompat
import androidx.appcompat.app.AppCompatDelegate
import android.widget.LinearLayout

class MyAccountPage : Fragment() {

Expand Down Expand Up @@ -121,6 +125,7 @@ class MyAccountPage : Fragment() {
settingsViewModel.loadStats()

setupAppearanceSection()
setupLanguageSection()
setupContentControlsSection()
}

Expand Down Expand Up @@ -201,7 +206,7 @@ class MyAccountPage : Fragment() {
if (host is AuthNavigator) {
host.openLogin()
} else {
Toast.makeText(requireContext(), "Login page not connected yet", Toast.LENGTH_SHORT)
Toast.makeText(requireContext(), getString(R.string.login_not_connected), Toast.LENGTH_SHORT)
.show()
}
}
Expand All @@ -212,6 +217,45 @@ class MyAccountPage : Fragment() {
setupThemeDemo(binding)
}

/**
* The in-app language picker.
*
* Android 13+ exposes a per-app language in the system settings, and
* `locales_config.xml` is what fills it — but the device this matters most
* on is a cheap TV box that shipped in a language its owner did not choose
* and would not know to change system-wide. So the same list is offered
* here, where they are already looking.
*
* `setApplicationLocales` recreates the activity itself; the AppCompat
* backport carries that down to API 21, so nothing here is gated on 13.
*/
private fun setupLanguageSection() {
val container = binding.languageChips
container.removeAllViews()

val current = AppCompatDelegate.getApplicationLocales()
.takeIf { !it.isEmpty }?.get(0)?.language
?: resources.configuration.locales[0].language

SUPPORTED_LOCALES.forEach { (tag, nativeName) ->
val chip = TvOptionChipView(requireContext()).apply {
text = nativeName
isSelected = tag == current
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
).apply { marginEnd = (12 * resources.displayMetrics.density).toInt() }
setOnClickListener {
if (tag == current) return@setOnClickListener
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(tag)
)
}
}
container.addView(chip)
}
}

private fun setupContentControlsSection() {
binding.contentControlsDropdown.setExpanded(
preferenceManager.isContentControlsExpanded(), animate = false
Expand Down Expand Up @@ -252,11 +296,22 @@ class MyAccountPage : Fragment() {
}

private fun updateContentControlsHeader() {
val ch = if (preferenceManager.isChannelEnabled()) "Enabled" else "Disabled"
val ns = if (preferenceManager.isNsfwEnabled()) "Enabled" else "Disabled"
val ch = getString(
if (preferenceManager.isChannelEnabled()) R.string.status_enabled
else R.string.status_disabled
)
val ns = getString(
if (preferenceManager.isNsfwEnabled()) R.string.status_enabled
else R.string.status_disabled
)

binding.contentControlsDropdown.setSummary("Home Channels: $ch • Adult: $ns")
binding.contentControlsDropdown.setBadge(if (preferenceManager.isNsfwEnabled()) "18+" else null)
binding.contentControlsDropdown.setSummary(
getString(R.string.content_controls_summary, ch, ns)
)
// "18+" is an age mark, not a word — the same in every language here.
binding.contentControlsDropdown.setBadge(
if (preferenceManager.isNsfwEnabled()) "18+" else null
)
}

private fun showNsfwWarningDialog() {
Expand Down Expand Up @@ -491,4 +546,27 @@ class MyAccountPage : Fragment() {
commit()
}
}

private companion object {
/**
* Kept in step with `res/xml/locales_config.xml`.
*
* Native names on purpose: someone looking for their own language finds
* it fastest written the way they write it, which is the whole point on
* a television that is currently speaking the wrong one.
*/
val SUPPORTED_LOCALES = listOf(
"en" to "English",
"uz" to "O‘zbekcha",
"ru" to "Русский",
"de" to "Deutsch",
"nl" to "Nederlands",
"es" to "Español",
"pt" to "Português",
"fr" to "Français",
"tr" to "Türkçe",
"id" to "Bahasa Indonesia",
)
}

}
Loading
Loading