commit eca2d5933d0166df900e85441c41af9e24afedf6 Author: mcgeezy Date: Mon Feb 23 14:07:47 2026 -0600 Initial RelayTV Android release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9bcb3e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Gradle +.gradle/ +build/ +app/build/ + +# Local config +local.properties + +# IDE +.idea/ +*.iml + +# Keystore +*.jks +*.keystore + +# OS +.DS_Store +Thumbs.db + +#Build + diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..300a8d3 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,45 @@ +plugins { + id "com.android.application" + id "org.jetbrains.kotlin.android" +} + +android { + namespace "pro.relaytv" + compileSdk 34 + + defaultConfig { + applicationId "pro.relaytv" + minSdk 26 + targetSdk 34 + versionCode 2 + versionName "1.1.0" + } + + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + } + debug { + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } +} + +dependencies { + implementation "androidx.core:core-ktx:1.13.1" + implementation "androidx.appcompat:appcompat:1.7.0" + implementation "androidx.activity:activity-ktx:1.9.2" + implementation "com.google.android.material:material:1.12.0" + implementation "androidx.core:core:1.13.1" + + implementation "com.squareup.okhttp3:okhttp:4.12.0" + implementation "androidx.work:work-runtime-ktx:2.9.1" +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..fb164d6 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..310ca75 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/pro/relaytv/HostStore.kt b/app/src/main/java/pro/relaytv/HostStore.kt new file mode 100644 index 0000000..4f7fac5 --- /dev/null +++ b/app/src/main/java/pro/relaytv/HostStore.kt @@ -0,0 +1,121 @@ +package pro.relaytv + +import android.content.Context +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.json.JSONArray +import org.json.JSONObject +import java.util.UUID + +data class RelayHost( + val id: String, + val name: String, + val baseUrl: String, +) + +object HostStore { + private const val PREF = "relaytv_prefs" + private const val KEY_HOSTS = "hosts_json" + private const val KEY_ACTIVE = "active_host_id" + + + fun normalizeBaseUrl(input: String): String? { + var s = input.trim() + if (s.isBlank()) return null + s = s.trimEnd('/') + // If user entered host:port (no scheme), default to http:// + if (!s.contains("://")) { + s = "http://" + s + } + val url = s.toHttpUrlOrNull() ?: return null + return url.toString().trimEnd('/') + } + +private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE) + + fun ensureMigrated(ctx: Context) { + // no-op + } + + fun loadHosts(ctx: Context): List { + ensureMigrated(ctx) + val raw = prefs(ctx).getString(KEY_HOSTS, null) ?: return emptyList() + return try { + val arr = JSONArray(raw) + buildList { + for (i in 0 until arr.length()) { + val o = arr.getJSONObject(i) + add( + RelayHost( + id = o.optString("id"), + name = o.optString("name", "Server"), + baseUrl = normalizeBaseUrl(o.optString("baseUrl")) ?: "", + ) + ) + } + }.filter { it.id.isNotBlank() && it.baseUrl.isNotBlank() } + } catch (_: Exception) { + emptyList() + } + } + + fun saveHosts(ctx: Context, hosts: List) { + val arr = JSONArray() + hosts.forEach { + arr.put( + JSONObject() + .put("id", it.id) + .put("name", it.name) + .put("baseUrl", normalizeBaseUrl(it.baseUrl) ?: "") + ) + } + prefs(ctx).edit().putString(KEY_HOSTS, arr.toString()).apply() + } + + fun getActiveHostId(ctx: Context): String? { + ensureMigrated(ctx) + return prefs(ctx).getString(KEY_ACTIVE, null) + } + + fun setActiveHostId(ctx: Context, id: String) { + prefs(ctx).edit().putString(KEY_ACTIVE, id).apply() + } + + fun getActiveHost(ctx: Context): RelayHost? { + val hosts = loadHosts(ctx) + if (hosts.isEmpty()) return null + val active = getActiveHostId(ctx) + val hit = hosts.firstOrNull { it.id == active } ?: hosts.first() + if (hit.id != active) setActiveHostId(ctx, hit.id) + return hit + } + + fun getActiveBaseUrl(ctx: Context): String? { + val hit = getActiveHost(ctx) ?: return null + return normalizeBaseUrl(hit.baseUrl) ?: "" + } + + fun upsert(ctx: Context, host: RelayHost) { + val hosts = loadHosts(ctx).toMutableList() + val idx = hosts.indexOfFirst { it.id == host.id } + if (idx >= 0) hosts[idx] = host else hosts.add(host) + saveHosts(ctx, hosts) + } + + fun remove(ctx: Context, id: String) { + val hosts = loadHosts(ctx).filterNot { it.id == id } + saveHosts(ctx, hosts) + val active = getActiveHostId(ctx) + if (active == id) { + val next = hosts.firstOrNull() + if (next != null) setActiveHostId(ctx, next.id) else prefs(ctx).edit().remove(KEY_ACTIVE).apply() + } + } + + fun create(ctx: Context, name: String, baseUrl: String): RelayHost { + val norm = normalizeBaseUrl(baseUrl) ?: "" + val host = RelayHost(UUID.randomUUID().toString(), name.ifBlank { "Server" }, norm) + upsert(ctx, host) + if (getActiveHostId(ctx).isNullOrBlank()) setActiveHostId(ctx, host.id) + return host + } +} diff --git a/app/src/main/java/pro/relaytv/MainActivity.kt b/app/src/main/java/pro/relaytv/MainActivity.kt new file mode 100644 index 0000000..b2f2fe1 --- /dev/null +++ b/app/src/main/java/pro/relaytv/MainActivity.kt @@ -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(R.id.listServers) + val btnAdd = view.findViewById(R.id.btnAdd) + val btnEdit = view.findViewById(R.id.btnEdit) + val btnRemove = view.findViewById(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() + } +} diff --git a/app/src/main/java/pro/relaytv/Net.kt b/app/src/main/java/pro/relaytv/Net.kt new file mode 100644 index 0000000..3120871 --- /dev/null +++ b/app/src/main/java/pro/relaytv/Net.kt @@ -0,0 +1,24 @@ +package pro.relaytv + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +object Net { + val client: OkHttpClient = OkHttpClient.Builder() + .callTimeout(30, TimeUnit.SECONDS) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(25, TimeUnit.SECONDS) + .writeTimeout(25, TimeUnit.SECONDS) + .build() + + fun get(url: String): Request = Request.Builder().url(url).get().build() + + fun postJson(url: String, json: String): Request { + val mt = "application/json; charset=utf-8".toMediaType() + val body = json.toRequestBody(mt) + return Request.Builder().url(url).post(body).build() + } +} diff --git a/app/src/main/java/pro/relaytv/SettingsActivity.kt b/app/src/main/java/pro/relaytv/SettingsActivity.kt new file mode 100644 index 0000000..c4a93a7 --- /dev/null +++ b/app/src/main/java/pro/relaytv/SettingsActivity.kt @@ -0,0 +1,18 @@ +package pro.relaytv + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity + +/** + * Kept for backward compatibility (older deep links / flows). + * This activity simply opens the server picker inside MainActivity. + */ +class SettingsActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + startActivity(android.content.Intent(this, MainActivity::class.java).apply { + putExtra("open_servers", true) + }) + finish() + } +} diff --git a/app/src/main/java/pro/relaytv/ShareActivity.kt b/app/src/main/java/pro/relaytv/ShareActivity.kt new file mode 100644 index 0000000..569c9ab --- /dev/null +++ b/app/src/main/java/pro/relaytv/ShareActivity.kt @@ -0,0 +1,49 @@ +package pro.relaytv + +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.work.Data +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager + +class ShareActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val base = HostStore.getActiveBaseUrl(this)?.trim()?.trimEnd('/') + if (base.isNullOrBlank()) { + // No server selected yet; open app so user can configure. + startActivity(Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra("open_servers", true) + }) + finish() + return + } + + val shared = intent.getStringExtra(Intent.EXTRA_TEXT) ?: "" + val url = extractUrl(shared) ?: run { finish(); return } + + val input = Data.Builder() + .putString(ShareWorker.KEY_BASE, base) + .putString(ShareWorker.KEY_URL, url) + .build() + + val req = OneTimeWorkRequestBuilder() + .setInputData(input) + .build() + + WorkManager.getInstance(this).enqueue(req) + val serverName = HostStore.getActiveHost(this)?.name?.ifBlank { "Server" } ?: "Server" + Toast.makeText(this, "Sent to \"$serverName\"", Toast.LENGTH_SHORT).show() + finishAndRemoveTask() + } + + private fun extractUrl(text: String): String? { + val m = Regex("""https?://\S+""").find(text) + return m?.value?.trim()?.trimEnd(')', ']', '>', '"', '\'') + } +} diff --git a/app/src/main/java/pro/relaytv/ShareWorker.kt b/app/src/main/java/pro/relaytv/ShareWorker.kt new file mode 100644 index 0000000..faba922 --- /dev/null +++ b/app/src/main/java/pro/relaytv/ShareWorker.kt @@ -0,0 +1,88 @@ +package pro.relaytv + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.Worker +import androidx.work.WorkerParameters +import org.json.JSONObject + +class ShareWorker(appContext: Context, params: WorkerParameters) : Worker(appContext, params) { + + companion object { + const val KEY_BASE = "base" + const val KEY_URL = "url" + private const val CHANNEL_ID = "relaytv_silent" + private const val NOTIF_ID = 4242 + } + + override fun doWork(): Result { + val base = inputData.getString(KEY_BASE)?.trim()?.trimEnd('/') ?: return Result.failure() + val url = inputData.getString(KEY_URL) ?: return Result.failure() + return try { + val payload = JSONObject().put("url", url).toString() + val req = Net.postJson(base + "/smart", payload) + + Net.client.newCall(req).execute().use { resp -> + val body = resp.body?.string().orEmpty() + val ok = resp.isSuccessful + val msg = runCatching { + val j = JSONObject(body) + when (j.optString("status")) { + "playing" -> "Playing now" + "queued" -> "Enqueued" + else -> if (ok) "Sent" else "Error" + } + }.getOrDefault(if (ok) "Sent" else "Error") + + postNotification(msg, url, tapToOpen = true) + if (ok) Result.success() else Result.retry() + } + } catch (e: Exception) { + val reason = (e.message ?: e.javaClass.simpleName).take(80) + if (reason.contains("timeout", ignoreCase = true)) { + postNotification("Sent (processing)", url, tapToOpen = true) + Result.success() + } else { + postNotification("Send failed: $reason", url, tapToOpen = true) + Result.retry() + } + } + } + + private fun postNotification(title: String, text: String, tapToOpen: Boolean) { + val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val ch = NotificationChannel(CHANNEL_ID, "RelayTV", NotificationManager.IMPORTANCE_LOW) + nm.createNotificationChannel(ch) + } + + val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_media_play) + .setContentTitle(title) + .setContentText(text.take(90)) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + + if (tapToOpen) { + val openIntent = Intent(applicationContext, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + val pending = PendingIntent.getActivity( + applicationContext, + 0, + openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0) + ) + builder.setContentIntent(pending) + } + + nm.notify(NOTIF_ID, builder.build()) + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..065e4b5 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..8830def --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,66 @@ + + + + + + + +