package pro.relaytv import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.net.ConnectivityManager import android.net.Network import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper 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 androidx.swiperefreshlayout.widget.SwipeRefreshLayout 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 uiHandler = Handler(Looper.getMainLooper()) private val connectivityManager by lazy { getSystemService(ConnectivityManager::class.java) } private lateinit var web: WebView private lateinit var toolbar: MaterialToolbar private lateinit var swipeRefresh: SwipeRefreshLayout 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 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) toolbar = findViewById(R.id.toolbar) web = findViewById(R.id.web) swipeRefresh = findViewById(R.id.swipeRefresh) toolbar.setOnMenuItemClickListener { item -> when (item.itemId) { R.id.action_servers -> { showServerPicker() true } R.id.action_reload -> { loadActiveServer(forcePickerOnFailure = false, manualRefresh = true) 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" swipeRefresh.setOnRefreshListener { loadActiveServer(forcePickerOnFailure = false, manualRefresh = true) } web.webChromeClient = WebChromeClient() web.webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView, url: String) { mainFrameFailed = false swipeRefresh.isRefreshing = false } override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) { if (request.isForMainFrame) { mainFrameFailed = true swipeRefresh.isRefreshing = false 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 onResume() { super.onResume() isInForeground = true registerNetworkCallback() if (!activeBaseUrl.isNullOrBlank()) { scheduleHeartbeat(2_000) } } 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()) { swipeRefresh.isRefreshing = false return } activeBaseUrl = normalized if (manualRefresh || !initialLoadComplete) { swipeRefresh.isRefreshing = true } 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()) { swipeRefresh.isRefreshing = false 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 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 swipeRefresh.isRefreshing = false scheduleHeartbeat(HEARTBEAT_OK_MS) } } }) } private fun onServerHealthFailed(forcePickerOnFailure: Boolean, manualRefresh: Boolean) { consecutiveHealthFailures += 1 swipeRefresh.isRefreshing = false 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, 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(R.id.listServers) val btnAdd = view.findViewById(R.id.btnAdd) val btnEdit = view.findViewById(R.id.btnEdit) val btnRemove = view.findViewById(R.id.btnRemove) val btnDiscover = view.findViewById(R.id.btnDiscover) 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() 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) = Unit override fun onFinished(servers: List) { 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 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 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() } }