* 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>
758 lines
30 KiB
Kotlin
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()
|
|
}
|
|
}
|