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>? = 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>, 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? { 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, 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) val txtActiveServer = view.findViewById(R.id.txtActiveServer) val hostStatuses = mutableMapOf() val statusChecksInFlight = mutableSetOf() 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) = 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 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() } }