Files
RelayTV-Android/app/src/main/java/pro/relaytv/MainActivity.kt
T
markandClaude Fable 5 edb367cca3 feat: system media controls (MediaSession) for the active server (#1)
* feat: add system media controls for the active server

Add a media3 MediaSessionService that mirrors playback on the active
RelayTV server as a system media session (lock screen / quick settings):

- MediaControlService polls GET /status and publishes state, metadata,
  artwork, and remote volume; forwards play/pause, next/previous, seek,
  stop, and volume to the server HTTP API
- RelayRemotePlayer implements SimpleBasePlayer over the remote state
- RemoteStatus parses /status defensively across server versions
- New Settings screen (toolbar menu) with a media-controls toggle and a
  Manage servers shortcut; fix open_servers intent handling via
  onNewIntent when MainActivity is already running

Release prep:

- Bump to versionCode 6 / versionName 1.3.0
- Wire optional release signing from MYAPP_RELEASE_* gradle properties
- Add FOREGROUND_SERVICE(_MEDIA_PLAYBACK) permissions and lint opt-ins
- Update privacy policy, release checklist (Play Console foreground
  service declaration), README, and Gradle daemon heap

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: sign release bundle in GitHub Actions

Decode the upload keystore from the RELEASE_KEYSTORE_BASE64 secret and pass
the MYAPP_RELEASE_* signing properties to Gradle via ORG_GRADLE_PROJECT_*
env vars, so tagged releases produce a signed .aab. Fail fast if the keystore
secret is missing, and verify the bundle signature after the build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:45:34 -05:00

758 lines
30 KiB
Kotlin

package pro.relaytv
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.webkit.ValueCallback
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.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.google.android.material.appbar.AppBarLayout
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
import kotlin.math.min
class MainActivity : AppCompatActivity() {
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ }
private val webFileChooser =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val callback = pendingWebFileCallback ?: return@registerForActivityResult
pendingWebFileCallback = null
val uris = if (result.resultCode == Activity.RESULT_OK) {
parseFileChooserResult(result.data)
} else {
null
}
callback.onReceiveValue(uris)
}
private val uiHandler = Handler(Looper.getMainLooper())
private val connectivityManager by lazy { getSystemService(ConnectivityManager::class.java) }
private lateinit var web: WebView
private lateinit var appBar: AppBarLayout
private lateinit var toolbar: MaterialToolbar
private var activeBaseUrl: String? = null
private var consecutiveHealthFailures = 0
private var healthRequestInFlight = false
private var mainFrameFailed = false
private var initialLoadComplete = false
private var networkCallbackRegistered = false
private var isInForeground = false
private var mdnsScanner: NsdRelayTvScanner? = null
private var pendingWebFileCallback: ValueCallback<Array<Uri>>? = null
private val heartbeatRunnable = Runnable { runHeartbeat() }
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
runOnUiThread { scheduleHeartbeat(500) }
}
}
companion object {
private const val HEARTBEAT_OK_MS = 15_000L
private const val HEARTBEAT_BASE_RETRY_MS = 4_000L
private const val HEARTBEAT_MAX_RETRY_MS = 60_000L
}
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)
appBar = findViewById(R.id.appBar)
toolbar = findViewById(R.id.toolbar)
web = findViewById(R.id.web)
applyWindowInsets()
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (this@MainActivity::web.isInitialized && web.canGoBack()) {
web.goBack()
return
}
isEnabled = false
onBackPressedDispatcher.onBackPressed()
isEnabled = true
}
})
toolbar.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.action_servers -> {
showServerPicker()
true
}
R.id.action_reload -> {
loadActiveServer(forcePickerOnFailure = false, manualRefresh = true)
true
}
R.id.action_settings -> {
startActivity(Intent(this, SettingsActivity::class.java))
true
}
R.id.action_privacy -> {
openPrivacyPolicy()
true
}
else -> false
}
}
configureWebView()
web.webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(
webView: WebView,
filePathCallback: ValueCallback<Array<Uri>>,
fileChooserParams: FileChooserParams
): Boolean {
pendingWebFileCallback?.onReceiveValue(null)
pendingWebFileCallback = filePathCallback
val intent = buildFileChooserIntent(fileChooserParams)
return try {
webFileChooser.launch(intent)
true
} catch (_: Exception) {
pendingWebFileCallback = null
filePathCallback.onReceiveValue(null)
Toast.makeText(this@MainActivity, getString(R.string.file_picker_open_failed), Toast.LENGTH_SHORT).show()
false
}
}
}
web.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
mainFrameFailed = false
}
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
if (request.isForMainFrame) {
mainFrameFailed = true
Toast.makeText(this@MainActivity, "Connection lost. Retrying…", Toast.LENGTH_SHORT).show()
scheduleHeartbeat(1_500)
}
}
}
val base = HostStore.getActiveBaseUrl(this)
if (openServers) {
showServerPicker(force = base.isNullOrBlank())
}
if (base.isNullOrBlank()) {
showServerPicker(force = true)
return
}
loadServerBase(base.trimEnd('/'), forcePickerOnFailure = true, manualRefresh = false)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (intent.getBooleanExtra("open_servers", false)) {
showServerPicker()
}
}
private fun buildFileChooserIntent(fileChooserParams: WebChromeClient.FileChooserParams): Intent {
val acceptTypes = fileChooserParams.acceptTypes
.orEmpty()
.flatMap { it.split(",") }
.map { it.trim().lowercase() }
.filter { it.isNotBlank() }
val mimeTypes = acceptTypes
.filter { !it.startsWith(".") && "/" in it }
.distinct()
.ifEmpty { listOf("*/*") }
.toTypedArray()
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = if (mimeTypes.size == 1) mimeTypes.first() else "*/*"
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
putExtra(
Intent.EXTRA_ALLOW_MULTIPLE,
fileChooserParams.mode == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE
)
}
return Intent.createChooser(intent, fileChooserParams.title ?: getString(R.string.select_file))
}
private fun parseFileChooserResult(data: Intent?): Array<Uri>? {
if (data == null) return null
val clipData = data.clipData
if (clipData != null && clipData.itemCount > 0) {
return Array(clipData.itemCount) { index -> clipData.getItemAt(index).uri }
.filterNotNull()
.takeIf { it.isNotEmpty() }
?.toTypedArray()
}
return data.data?.let { arrayOf(it) }
}
@SuppressLint("SetJavaScriptEnabled")
private fun configureWebView() {
val versionName = runCatching {
packageManager.getPackageInfo(packageName, 0).versionName
}.getOrNull() ?: "dev"
web.settings.javaScriptEnabled = true
web.settings.domStorageEnabled = true
web.settings.mediaPlaybackRequiresUserGesture = false
web.settings.userAgentString = web.settings.userAgentString + " RelayTV/$versionName"
}
private fun applyWindowInsets() {
val initialTopPadding = appBar.paddingTop
val initialLeftPadding = appBar.paddingLeft
val initialRightPadding = appBar.paddingRight
ViewCompat.setOnApplyWindowInsetsListener(appBar) { view: View, insets: WindowInsetsCompat ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(
initialLeftPadding + systemBars.left,
initialTopPadding + systemBars.top,
initialRightPadding + systemBars.right,
view.paddingBottom
)
insets
}
ViewCompat.requestApplyInsets(appBar)
}
private fun openPrivacyPolicy() {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.privacy_policy_url)))
runCatching {
startActivity(browserIntent)
}.onFailure {
Toast.makeText(this, getString(R.string.privacy_open_failed), Toast.LENGTH_SHORT).show()
}
}
override fun onResume() {
super.onResume()
isInForeground = true
registerNetworkCallback()
ensureMediaControlService()
if (!activeBaseUrl.isNullOrBlank()) {
scheduleHeartbeat(2_000)
}
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private fun ensureMediaControlService() {
if (!AppSettings.isMediaControlsEnabled(this)) return
if (HostStore.getActiveBaseUrl(this).isNullOrBlank()) return
runCatching { startService(Intent(this, MediaControlService::class.java)) }
}
override fun onPause() {
super.onPause()
isInForeground = false
unregisterNetworkCallback()
uiHandler.removeCallbacks(heartbeatRunnable)
}
override fun onDestroy() {
uiHandler.removeCallbacksAndMessages(null)
unregisterNetworkCallback()
mdnsScanner?.stop()
super.onDestroy()
}
private fun loadServerBase(base: String, forcePickerOnFailure: Boolean, manualRefresh: Boolean) {
val normalized = base.trimEnd('/')
if (normalized.isBlank()) {
return
}
activeBaseUrl = normalized
probeServerHealth(
base = normalized,
forcePickerOnFailure = forcePickerOnFailure,
loadUiOnSuccess = true,
manualRefresh = manualRefresh
)
}
private fun loadActiveServer(forcePickerOnFailure: Boolean, manualRefresh: Boolean) {
val base = activeBaseUrl ?: HostStore.getActiveBaseUrl(this)?.trimEnd('/')
if (base.isNullOrBlank()) {
showServerPicker(force = true)
return
}
loadServerBase(base, forcePickerOnFailure = forcePickerOnFailure, manualRefresh = manualRefresh)
}
private fun probeServerHealth(
base: String,
forcePickerOnFailure: Boolean,
loadUiOnSuccess: Boolean,
manualRefresh: Boolean,
fromHeartbeat: Boolean = false,
) {
if (healthRequestInFlight) return
if (fromHeartbeat && !isInForeground) return
val req = Net.get(base + "/health")
healthRequestInFlight = true
Net.client.newCall(req).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
runOnUiThread {
healthRequestInFlight = false
onServerHealthFailed(
forcePickerOnFailure = forcePickerOnFailure,
manualRefresh = manualRefresh
)
}
}
override fun onResponse(call: Call, response: Response) {
val healthy = response.use { it.isSuccessful }
runOnUiThread {
healthRequestInFlight = false
if (!healthy) {
onServerHealthFailed(
forcePickerOnFailure = forcePickerOnFailure,
manualRefresh = manualRefresh
)
return@runOnUiThread
}
val recovered = consecutiveHealthFailures > 0
consecutiveHealthFailures = 0
// Keep the media-controls service alive while we can see the server.
ensureMediaControlService()
if (recovered && !manualRefresh) {
Toast.makeText(this@MainActivity, "Reconnected to RelayTV.", Toast.LENGTH_SHORT).show()
}
if (loadUiOnSuccess) {
val uiUrl = "$base/ui"
val shouldLoad = manualRefresh || mainFrameFailed || web.url.isNullOrBlank() || !web.url.orEmpty().startsWith(uiUrl)
if (shouldLoad) {
web.loadUrl(uiUrl)
}
}
mainFrameFailed = false
initialLoadComplete = true
scheduleHeartbeat(HEARTBEAT_OK_MS)
}
}
})
}
private fun onServerHealthFailed(forcePickerOnFailure: Boolean, manualRefresh: Boolean) {
consecutiveHealthFailures += 1
if (forcePickerOnFailure && !initialLoadComplete) {
Toast.makeText(this, "Server not reachable. Switch servers.", Toast.LENGTH_LONG).show()
showServerPicker(force = true)
return
}
if (manualRefresh) {
Toast.makeText(this, "Can't reach server right now.", Toast.LENGTH_SHORT).show()
} else if (consecutiveHealthFailures == 1) {
Toast.makeText(this, "Connection lost. Retrying…", Toast.LENGTH_SHORT).show()
}
scheduleHeartbeat(nextRetryDelayMs())
}
private fun nextRetryDelayMs(): Long {
val cappedPower = min(4, (consecutiveHealthFailures - 1).coerceAtLeast(0))
val delay = HEARTBEAT_BASE_RETRY_MS * (1L shl cappedPower)
return min(delay, HEARTBEAT_MAX_RETRY_MS)
}
private fun runHeartbeat() {
if (!isInForeground) return
val base = activeBaseUrl ?: HostStore.getActiveBaseUrl(this)?.trimEnd('/') ?: return
probeServerHealth(
base = base,
forcePickerOnFailure = false,
loadUiOnSuccess = mainFrameFailed || web.url.isNullOrBlank(),
manualRefresh = false,
fromHeartbeat = true
)
}
private fun scheduleHeartbeat(delayMs: Long) {
uiHandler.removeCallbacks(heartbeatRunnable)
if (!isInForeground) return
if (activeBaseUrl.isNullOrBlank()) return
uiHandler.postDelayed(heartbeatRunnable, delayMs)
}
private fun registerNetworkCallback() {
if (networkCallbackRegistered) return
runCatching {
connectivityManager.registerDefaultNetworkCallback(networkCallback)
networkCallbackRegistered = true
}
}
private fun unregisterNetworkCallback() {
if (!networkCallbackRegistered) return
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
networkCallbackRegistered = false
}
private fun upsertDiscoveredServer(server: DiscoveredRelayTvServer): RelayHost {
val normalized = HostStore.normalizeBaseUrl(server.baseUrl) ?: server.baseUrl
val hosts = HostStore.loadHosts(this)
val existing = hosts.firstOrNull {
HostStore.normalizeBaseUrl(it.baseUrl) == normalized
}
return if (existing != null) {
existing
} else {
HostStore.create(this, server.name.ifBlank { "RelayTV" }, normalized)
}
}
private fun showDiscoveredServersDialog(
servers: List<DiscoveredRelayTvServer>,
onPick: (DiscoveredRelayTvServer) -> Unit,
) {
val labels = servers.map { "${it.name}${it.baseUrl}" }.toTypedArray()
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.discovered_servers))
.setItems(labels) { _, which ->
val chosen = servers.getOrNull(which) ?: return@setItems
onPick(chosen)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
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)
val btnDiscover = view.findViewById<com.google.android.material.button.MaterialButton>(R.id.btnDiscover)
val txtActiveServer = view.findViewById<android.widget.TextView>(R.id.txtActiveServer)
val hostStatuses = mutableMapOf<String, String>()
val statusChecksInFlight = mutableSetOf<String>()
lateinit var refresh: (String?) -> Unit
fun selectedHostIdFromList(): String? {
val hosts = HostStore.loadHosts(this)
val pos = list.checkedItemPosition
return hosts.getOrNull(pos)?.id
}
fun renderActiveServer(selectionId: String?) {
val hosts = HostStore.loadHosts(this)
val active = hosts.firstOrNull { it.id == selectionId }
?: hosts.firstOrNull { it.id == HostStore.getActiveHostId(this) }
?: hosts.firstOrNull()
txtActiveServer.text = if (active != null) {
"${active.name}\n${active.baseUrl}"
} else {
getString(R.string.no_server_selected)
}
}
fun statusLabelFor(host: RelayHost): String {
return hostStatuses[host.id] ?: getString(R.string.status_checking)
}
fun scheduleHostStatusCheck(host: RelayHost) {
if (statusChecksInFlight.contains(host.id)) return
if (hostStatuses.containsKey(host.id)) return
statusChecksInFlight.add(host.id)
hostStatuses[host.id] = getString(R.string.status_checking)
val req = Net.get(host.baseUrl.trimEnd('/') + "/health")
Net.client.newCall(req).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
runOnUiThread {
statusChecksInFlight.remove(host.id)
hostStatuses[host.id] = getString(R.string.status_offline)
refresh(selectedHostIdFromList() ?: HostStore.getActiveHostId(this@MainActivity))
}
}
override fun onResponse(call: Call, response: Response) {
val ok = response.use { it.isSuccessful }
runOnUiThread {
statusChecksInFlight.remove(host.id)
hostStatuses[host.id] = if (ok) {
getString(R.string.status_online)
} else {
getString(R.string.status_offline)
}
refresh(selectedHostIdFromList() ?: HostStore.getActiveHostId(this@MainActivity))
}
}
})
}
refresh = { selectionIdInput ->
val selectionId = selectionIdInput ?: HostStore.getActiveHostId(this)
val hosts = HostStore.loadHosts(this)
val activeId = HostStore.getActiveHostId(this)
val labels = hosts.map { host ->
val prefix = if (host.id == activeId) {
getString(R.string.status_active_prefix) + " • "
} else {
""
}
"${host.name}${prefix}${statusLabelFor(host)}\n${host.baseUrl}"
}
list.adapter = ArrayAdapter(this, R.layout.item_server_picker_choice, android.R.id.text1, 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()
renderActiveServer(selectionId)
hosts.forEach { scheduleHostStatusCheck(it) }
}
refresh(HostStore.getActiveHostId(this))
btnDiscover.text = getString(R.string.scan_lan)
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
loadServerBase(chosen.baseUrl, forcePickerOnFailure = true, manualRefresh = false)
d.dismiss()
}
.create()
dialog.setOnDismissListener {
mdnsScanner?.stop()
btnDiscover.text = getString(R.string.scan_lan)
btnDiscover.isEnabled = true
}
btnDiscover.setOnClickListener {
btnDiscover.isEnabled = false
btnDiscover.text = getString(R.string.scanning_lan)
val scanner = mdnsScanner ?: NsdRelayTvScanner(this).also { mdnsScanner = it }
scanner.scan(callback = object : NsdRelayTvScanner.Callback {
override fun onUpdate(servers: List<DiscoveredRelayTvServer>) = Unit
override fun onFinished(servers: List<DiscoveredRelayTvServer>) {
btnDiscover.text = getString(R.string.scan_lan)
btnDiscover.isEnabled = true
if (!dialog.isShowing) return
if (servers.isEmpty()) {
Toast.makeText(this@MainActivity, getString(R.string.discovery_none), Toast.LENGTH_SHORT).show()
return
}
showDiscoveredServersDialog(servers) { selected ->
val host = upsertDiscoveredServer(selected)
HostStore.setActiveHostId(this@MainActivity, host.id)
toolbar.subtitle = host.name
hostStatuses.remove(host.id)
statusChecksInFlight.remove(host.id)
refresh(host.id)
loadServerBase(host.baseUrl, forcePickerOnFailure = false, manualRefresh = false)
dialog.dismiss()
}
}
override fun onError(message: String) {
btnDiscover.text = getString(R.string.scan_lan)
btnDiscover.isEnabled = true
if (!dialog.isShowing) return
val msg = message.ifBlank { getString(R.string.discovery_failed) }
Toast.makeText(this@MainActivity, msg, Toast.LENGTH_SHORT).show()
}
})
}
list.setOnItemClickListener { _, _, position, _ ->
val hosts = HostStore.loadHosts(this)
val chosen = hosts.getOrNull(position) ?: return@setOnItemClickListener
HostStore.setActiveHostId(this, chosen.id)
toolbar.subtitle = chosen.name
renderActiveServer(chosen.id)
loadServerBase(chosen.baseUrl, forcePickerOnFailure = true, manualRefresh = false)
dialog.dismiss()
}
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)
hostStatuses.remove(host.id)
statusChecksInFlight.remove(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)
hostStatuses.remove(existing.id)
refresh(HostStore.getActiveHostId(this))
}
.show()
}
// Show active server on toolbar
HostStore.loadHosts(this).firstOrNull { it.id == HostStore.getActiveHostId(this) }?.let {
toolbar.subtitle = it.name
}
dialog.show()
}
}