Initial RelayTV Android release

This commit is contained in:
2026-02-23 14:07:47 -06:00
commit eca2d5933d
35 changed files with 1282 additions and 0 deletions
@@ -0,0 +1,288 @@
package pro.relaytv
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.EditText
import android.widget.ListView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import java.io.IOException
class MainActivity : AppCompatActivity() {
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ }
private lateinit var web: WebView
private lateinit var toolbar: MaterialToolbar
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Android 13+ requires runtime permission for notifications.
if (Build.VERSION.SDK_INT >= 33) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
setContentView(R.layout.activity_main)
val openServers = intent.getBooleanExtra("open_servers", false)
toolbar = findViewById(R.id.toolbar)
web = findViewById(R.id.web)
toolbar.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.action_servers -> {
showServerPicker()
true
}
R.id.action_reload -> {
web.reload()
true
}
else -> false
}
}
web.settings.javaScriptEnabled = true
web.settings.domStorageEnabled = true
web.settings.mediaPlaybackRequiresUserGesture = false
web.settings.userAgentString = web.settings.userAgentString + " RelayTV/1.1.0"
web.webChromeClient = WebChromeClient()
web.webViewClient = object : WebViewClient() {
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
if (request.isForMainFrame) {
Toast.makeText(this@MainActivity, "Can't reach server. Switch servers.", Toast.LENGTH_LONG).show()
}
}
}
val base = HostStore.getActiveBaseUrl(this)
if (openServers) {
showServerPicker(force = base.isNullOrBlank())
}
if (base.isNullOrBlank()) {
showServerPicker(force = true)
return
}
checkHealthAndLoad(base.trimEnd('/'))
}
private fun checkHealthAndLoad(base: String) {
val req = Net.get(base + "/health")
Net.client.newCall(req).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
runOnUiThread {
Toast.makeText(this@MainActivity, "Server not reachable. Switch servers.", Toast.LENGTH_LONG).show()
showServerPicker(force = true)
}
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!it.isSuccessful) {
runOnUiThread {
Toast.makeText(this@MainActivity, "Not a RelayTV server (HTTP ${it.code}).", Toast.LENGTH_LONG).show()
showServerPicker(force = true)
}
return
}
}
runOnUiThread { web.loadUrl(base + "/ui") }
}
})
}
private fun showServerPicker(force: Boolean = false) {
val view = LayoutInflater.from(this).inflate(R.layout.dialog_server_picker, null)
val list = view.findViewById<ListView>(R.id.listServers)
val btnAdd = view.findViewById<com.google.android.material.button.MaterialButton>(R.id.btnAdd)
val btnEdit = view.findViewById<com.google.android.material.button.MaterialButton>(R.id.btnEdit)
val btnRemove = view.findViewById<com.google.android.material.button.MaterialButton>(R.id.btnRemove)
fun refresh(selectionId: String? = HostStore.getActiveHostId(this)) {
val hosts = HostStore.loadHosts(this)
val labels = hosts.map { "${it.name}${it.baseUrl}" }
list.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_single_choice, labels)
list.choiceMode = ListView.CHOICE_MODE_SINGLE
val idx = hosts.indexOfFirst { it.id == selectionId }.let { if (it >= 0) it else 0 }
if (hosts.isNotEmpty()) list.setItemChecked(idx, true)
btnEdit.isEnabled = hosts.isNotEmpty()
btnRemove.isEnabled = hosts.isNotEmpty()
}
refresh()
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.select_server))
.setView(view)
.setNegativeButton(if (force) "Exit" else "Close") { d, _ ->
d.dismiss()
if (force && HostStore.getActiveBaseUrl(this).isNullOrBlank()) {
finish()
}
}
.setPositiveButton("Use") { d, _ ->
val hosts = HostStore.loadHosts(this)
if (hosts.isEmpty()) {
Toast.makeText(this, "Add a server first.", Toast.LENGTH_SHORT).show()
return@setPositiveButton
}
val pos = list.checkedItemPosition.coerceAtLeast(0)
val chosen = hosts.getOrNull(pos) ?: hosts.first()
HostStore.setActiveHostId(this, chosen.id)
toolbar.subtitle = chosen.name
checkHealthAndLoad(chosen.baseUrl)
d.dismiss()
}
.create()
list.setOnItemClickListener { _, _, position, _ ->
val hosts = HostStore.loadHosts(this)
val chosen = hosts.getOrNull(position) ?: return@setOnItemClickListener
toolbar.subtitle = chosen.name
}
fun showAddEdit(existing: RelayHost? = null) {
val nameInput = EditText(this).apply {
hint = getString(R.string.server_name)
setText(existing?.name ?: "")
}
val urlInput = EditText(this).apply {
hint = getString(R.string.server_url)
setText(existing?.baseUrl ?: "")
inputType = android.text.InputType.TYPE_TEXT_VARIATION_URI
}
val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
val pad = (16 * resources.displayMetrics.density).toInt()
setPadding(pad, (8 * resources.displayMetrics.density).toInt(), pad, 0)
addView(nameInput)
addView(urlInput)
}
val dlg = MaterialAlertDialogBuilder(this)
.setTitle(if (existing == null) getString(R.string.add_server) else getString(R.string.edit_server))
.setView(container)
.setNegativeButton(android.R.string.cancel, null)
// We override the positive click to keep the dialog open on validation errors.
.setPositiveButton("Save", null)
.create()
dlg.setOnShowListener {
val btn = dlg.getButton(AlertDialog.BUTTON_POSITIVE)
btn.setOnClickListener {
val name = nameInput.text.toString().trim().ifBlank { "Server" }
val raw = urlInput.text.toString()
val base = HostStore.normalizeBaseUrl(raw)
if (base.isNullOrBlank()) {
Toast.makeText(this, "Enter a valid base URL (example: http://10.0.55.2:8787).", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
btn.isEnabled = false
Toast.makeText(this, "Verifying server…", Toast.LENGTH_SHORT).show()
val req = Net.get(base + "/health")
Net.client.newCall(req).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
runOnUiThread {
btn.isEnabled = true
Toast.makeText(this@MainActivity, "Can't reach server. Check address.", Toast.LENGTH_LONG).show()
}
}
override fun onResponse(call: Call, response: Response) {
val ok = response.isSuccessful && try {
val body = response.body?.string() ?: ""
val o = org.json.JSONObject(body)
o.optBoolean("ok", false)
} catch (_: Exception) { false }
runOnUiThread {
if (!ok) {
btn.isEnabled = true
Toast.makeText(this@MainActivity, "Not a RelayTV server (check /health).", Toast.LENGTH_LONG).show()
return@runOnUiThread
}
val host = if (existing == null) {
HostStore.create(this@MainActivity, name, base)
} else {
val updated = existing.copy(name = name, baseUrl = base)
HostStore.upsert(this@MainActivity, updated)
updated
}
HostStore.setActiveHostId(this@MainActivity, host.id)
refresh(host.id)
dlg.dismiss()
}
}
})
}
}
dlg.show()
}
btnAdd.setOnClickListener { showAddEdit(null) }
btnEdit.setOnClickListener {
val hosts = HostStore.loadHosts(this)
val pos = list.checkedItemPosition.coerceAtLeast(0)
val existing = hosts.getOrNull(pos) ?: return@setOnClickListener
showAddEdit(existing)
}
btnRemove.setOnClickListener {
val hosts = HostStore.loadHosts(this)
val pos = list.checkedItemPosition.coerceAtLeast(0)
val existing = hosts.getOrNull(pos) ?: return@setOnClickListener
MaterialAlertDialogBuilder(this)
.setTitle("Remove server?")
.setMessage("Remove ${existing.name}?")
.setNegativeButton("Cancel", null)
.setPositiveButton("Remove") { _, _ ->
HostStore.remove(this, existing.id)
refresh()
}
.show()
}
// Show active server on toolbar
HostStore.loadHosts(this).firstOrNull { it.id == HostStore.getActiveHostId(this) }?.let {
toolbar.subtitle = it.name
}
dialog.show()
}
override fun onBackPressed() {
if (this::web.isInitialized && web.canGoBack()) web.goBack() else super.onBackPressed()
}
}