chore: prepare Android app for release

This commit is contained in:
2026-04-03 16:37:23 -05:00
parent 2719f2852a
commit e8be7a4fc2
25 changed files with 475 additions and 308 deletions
+56 -173
View File
@@ -1,207 +1,90 @@
# 📺 RelayTV Android Remote
# RelayTV Android Remote
**RelayTV Android** is the official companion remote app for
**RelayTV**, a powerful local-first media relay and playback system.
RelayTV Android is the Android companion app for the self-hosted RelayTV server. It gives you a lightweight remote, embedded `/ui` access, multi-server management, and Android share targets for queueing or immediately playing links on a RelayTV box.
Control, launch, and share content to your RelayTV server instantly from
Android --- with multi-server support and seamless share integration.
------------------------------------------------------------------------
## 🚀 What is RelayTV?
RelayTV is a local media relay and playback server designed for:
- YouTube & streaming links
- Direct media URLs
- Queue management
- HDMI playback (mpv-based)
- LAN-first operation
- Smart `/smart` auto-routing endpoint
🔗 RelayTV Server:\
RelayTV server:
https://github.com/mcgeezy/relaytv
🔗 Home Assistant Integration:\
Home Assistant integration:
https://github.com/mcgeezy/relaytv-ha
------------------------------------------------------------------------
## Features
# ✨ Features
- Multi-server support with health verification before save
- LAN auto-discovery of `_relaytv._tcp` servers with Android NSD / mDNS
- Embedded RelayTV `/ui` WebView with reconnect and heartbeat recovery
- Dedicated Android share targets:
- `RelayTV Queue` -> `POST /smart`
- `RelayTV Play` -> `POST /play_now`
- HTTP and HTTPS support for self-hosted RelayTV servers
## 📡 Multi-Server Support
## Requirements
- Add unlimited RelayTV servers
- Name each server (Living Room, Office, Bedroom, etc.)
- Instantly switch active server
- Remove or edit servers anytime
- Built-in `/health` verification before saving
- Android 8.0+ (`minSdk 26`)
- RelayTV server reachable on your LAN or via a trusted remote URL
------------------------------------------------------------------------
## 🔗 Share to RelayTV
- Share any link from Android to RelayTV
- Automatically sends to:
POST /smart
- Smart endpoint handles play vs queue logic
- Instant feedback toast:
Sent to "Living Room"
------------------------------------------------------------------------
## 🎨 Clean RelayTV UI
- Material 3 design
- Dark-mode optimized
- RelayTV branding (electric blue + charcoal theme)
- Quick server switch from toolbar
- Pull-to-refresh WebView
------------------------------------------------------------------------
## 🧠 Smart URL Handling
- Auto-normalizes URLs:
- 10.0.55.2:8787 → http://10.0.55.2:8787
- Validates server before saving
- Prevents crash loops from invalid input
------------------------------------------------------------------------
## 📱 WebView Relay Mode
Loads:
/ui
from the active server for full RelayTV interface access.
------------------------------------------------------------------------
# 🏗 Requirements
- Android 8.0+
- RelayTV server running locally or remotely
- HTTP or HTTPS supported
------------------------------------------------------------------------
# 🔐 HTTPS Notes
RelayTV supports both:
http://
https://
For best compatibility:
- Prefer HTTPS
- Use a valid certificate (reverse proxy, Nginx Proxy Manager, etc.)
- Or use HTTP on trusted LAN environments
------------------------------------------------------------------------
# 🛠 Build Instructions
Clone:
git clone https://github.com/mcgeezy/relaytv-android.git
cd relaytv-android
Debug build:
./gradlew assembleDebug
Release bundle (for Play):
./gradlew bundleRelease
------------------------------------------------------------------------
## 🔧 Local Build/Test Scripts
## Build And Test
Validate host environment:
./scripts/check-android-env.sh all
```bash
./scripts/check-android-env.sh all
```
Install SDK components (requires sdkmanager):
Install or refresh Android SDK components:
./scripts/bootstrap-android.sh
```bash
./scripts/bootstrap-android.sh
```
Build + lint + unit tests:
Debug build, lint, and unit tests:
./scripts/build-debug.sh
```bash
./scripts/build-debug.sh
```
Connected/device tests:
Release bundle and release lint:
./scripts/test-connected.sh
```bash
./scripts/build-release.sh
```
------------------------------------------------------------------------
Connected tests on a physical device or emulator:
# 📦 Play Store Ready
```bash
./scripts/test-connected.sh
```
Release build includes:
## Release Notes
- Resource shrinking
- Code minification
- Material 3 theming
- Clean package namespace
- Current Play target: Android 15 / API 35
- Release bundle output: `app/build/outputs/bundle/release/app-release.aab`
- Release checklist: [docs/RELEASE_CHECKLIST.md](/opt/dev/relaytv-android/docs/RELEASE_CHECKLIST.md)
- Privacy policy draft: [docs/PRIVACY_POLICY.md](/opt/dev/relaytv-android/docs/PRIVACY_POLICY.md)
Before Play upload:
## Network Notes
- Configure signing keystore
- Increment versionCode
- Generate .aab
RelayTV Android intentionally supports both:
------------------------------------------------------------------------
- `http://`
- `https://`
# 🏠 Home Assistant Integration
HTTP support is retained because RelayTV is commonly deployed as a trusted LAN-first service on local IPs and hostnames. If your server is reachable outside your private network, use HTTPS.
RelayTV integrates directly with Home Assistant via:
## API Surface Used By The App
https://github.com/mcgeezy/relaytv-ha
- `GET /health`
- `GET /ui`
- `POST /smart`
- `POST /play_now`
Features include:
## Contributing
- Media player entity
- Queue control
- Playback control
- Side panel iframe UI
- Multi-server support
- Keep Android UX aligned with RelayTVs self-hosted, local-first model
- Preserve the queue vs play-now share behavior
- Avoid regressions in reconnect and server discovery flows
------------------------------------------------------------------------
# 🔄 API Reference
RelayTV Android primarily uses:
POST /smart
GET /health
GET /ui
Full API reference available in:
## License
Same license as the RelayTV core project:
https://github.com/mcgeezy/relaytv
------------------------------------------------------------------------
# 🤝 Contributing
Contributions welcome!
- Keep UI consistent with RelayTV branding
- Maintain `/smart` routing behavior
- Preserve multi-server architecture
------------------------------------------------------------------------
# 📄 License
Same license as RelayTV core project.\
See: https://github.com/mcgeezy/relaytv
+19 -15
View File
@@ -4,33 +4,38 @@ plugins {
}
android {
namespace "pro.relaytv"
compileSdk 34
namespace = "pro.relaytv"
compileSdk = 35
defaultConfig {
applicationId "pro.relaytv"
minSdk 26
targetSdk 34
versionCode 2
versionName "1.1.0"
applicationId = "pro.relaytv"
minSdk = 26
targetSdk = 35
versionCode = 3
versionName = "1.2.0"
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
minifyEnabled = true
shrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
minifyEnabled false
minifyEnabled = false
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
kotlinOptions { jvmTarget = "17" }
}
dependencies {
@@ -38,7 +43,6 @@ dependencies {
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.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "com.squareup.okhttp3:okhttp:4.12.0"
implementation "androidx.work:work-runtime-ktx:2.9.1"
+47 -3
View File
@@ -6,7 +6,9 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:allowBackup="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -26,18 +28,60 @@
<activity
android:name=".ShareActivity"
android:exported="true"
android:exported="false"
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true"
android:taskAffinity=""
android:launchMode="singleTask"
android:theme="@android:style/Theme.NoDisplay"/>
<activity-alias
android:name=".ShareQueueAlias"
android:targetActivity=".ShareActivity"
android:exported="true"
android:enabled="true"
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true"
android:taskAffinity=""
android:launchMode="singleTask"
android:label="@string/share_queue_label"
android:theme="@android:style/Theme.NoDisplay">
<meta-data
android:name="pro.relaytv.SHARE_ENDPOINT_PATH"
android:value="/smart" />
<meta-data
android:name="pro.relaytv.SHARE_SUCCESS_TEMPLATE"
android:value="@string/share_queue_toast" />
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/plain"/>
</intent-filter>
</activity>
</activity-alias>
<activity-alias
android:name=".SharePlayNowAlias"
android:targetActivity=".ShareActivity"
android:exported="true"
android:enabled="true"
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true"
android:taskAffinity=""
android:launchMode="singleTask"
android:label="@string/share_play_now_label"
android:theme="@android:style/Theme.NoDisplay">
<meta-data
android:name="pro.relaytv.SHARE_ENDPOINT_PATH"
android:value="/play_now" />
<meta-data
android:name="pro.relaytv.SHARE_SUCCESS_TEMPLATE"
android:value="@string/share_play_now_toast" />
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/plain"/>
</intent-filter>
</activity-alias>
<activity
android:name=".SettingsActivity"
+27 -19
View File
@@ -1,10 +1,12 @@
package pro.relaytv
import android.Manifest
import android.annotation.SuppressLint
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
@@ -25,7 +27,6 @@ import androidx.activity.OnBackPressedCallback
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
@@ -44,7 +45,6 @@ class MainActivity : AppCompatActivity() {
private lateinit var web: WebView
private lateinit var toolbar: MaterialToolbar
private lateinit var swipeRefresh: SwipeRefreshLayout
private var activeBaseUrl: String? = null
private var consecutiveHealthFailures = 0
@@ -83,7 +83,6 @@ class MainActivity : AppCompatActivity() {
val openServers = intent.getBooleanExtra("open_servers", false)
toolbar = findViewById(R.id.toolbar)
web = findViewById(R.id.web)
swipeRefresh = findViewById(R.id.swipeRefresh)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (this@MainActivity::web.isInitialized && web.canGoBack()) {
@@ -106,29 +105,25 @@ class MainActivity : AppCompatActivity() {
loadActiveServer(forcePickerOnFailure = false, manualRefresh = true)
true
}
R.id.action_privacy -> {
openPrivacyPolicy()
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)
}
configureWebView()
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)
}
@@ -147,6 +142,26 @@ class MainActivity : AppCompatActivity() {
loadServerBase(base.trimEnd('/'), forcePickerOnFailure = true, manualRefresh = false)
}
@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 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
@@ -173,13 +188,9 @@ class MainActivity : AppCompatActivity() {
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,
@@ -191,7 +202,6 @@ class MainActivity : AppCompatActivity() {
private fun loadActiveServer(forcePickerOnFailure: Boolean, manualRefresh: Boolean) {
val base = activeBaseUrl ?: HostStore.getActiveBaseUrl(this)?.trimEnd('/')
if (base.isNullOrBlank()) {
swipeRefresh.isRefreshing = false
showServerPicker(force = true)
return
}
@@ -248,7 +258,6 @@ class MainActivity : AppCompatActivity() {
mainFrameFailed = false
initialLoadComplete = true
swipeRefresh.isRefreshing = false
scheduleHeartbeat(HEARTBEAT_OK_MS)
}
}
@@ -257,7 +266,6 @@ class MainActivity : AppCompatActivity() {
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()
+20 -1
View File
@@ -1,5 +1,6 @@
package pro.relaytv
import android.content.pm.PackageManager
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
@@ -10,6 +11,11 @@ import androidx.work.WorkManager
class ShareActivity : AppCompatActivity() {
companion object {
private const val META_ENDPOINT_PATH = "pro.relaytv.SHARE_ENDPOINT_PATH"
private const val META_SUCCESS_TEMPLATE = "pro.relaytv.SHARE_SUCCESS_TEMPLATE"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -26,10 +32,13 @@ class ShareActivity : AppCompatActivity() {
val shared = intent.getStringExtra(Intent.EXTRA_TEXT) ?: ""
val url = extractUrl(shared) ?: run { finish(); return }
val endpointPath = resolveMetaString(META_ENDPOINT_PATH).ifBlank { "/smart" }
val successTemplate = resolveMetaString(META_SUCCESS_TEMPLATE).ifBlank { "Sent to \"%s\"" }
val input = Data.Builder()
.putString(ShareWorker.KEY_BASE, base)
.putString(ShareWorker.KEY_URL, url)
.putString(ShareWorker.KEY_ENDPOINT_PATH, endpointPath)
.build()
val req = OneTimeWorkRequestBuilder<ShareWorker>()
@@ -38,10 +47,20 @@ class ShareActivity : AppCompatActivity() {
WorkManager.getInstance(this).enqueue(req)
val serverName = HostStore.getActiveHost(this)?.name?.ifBlank { "Server" } ?: "Server"
Toast.makeText(this, "Sent to \"$serverName\"", Toast.LENGTH_SHORT).show()
Toast.makeText(this, successTemplate.format(serverName), Toast.LENGTH_SHORT).show()
finishAndRemoveTask()
}
private fun resolveMetaString(key: String): String {
return try {
val flags = PackageManager.GET_META_DATA
val info = packageManager.getActivityInfo(componentName, flags)
info.metaData?.getString(key).orEmpty()
} catch (_: Exception) {
""
}
}
private fun extractUrl(text: String): String? {
val m = Regex("""https?://\S+""").find(text)
return m?.value?.trim()?.trimEnd(')', ']', '>', '"', '\'')
+6 -7
View File
@@ -5,7 +5,6 @@ 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
@@ -16,6 +15,7 @@ class ShareWorker(appContext: Context, params: WorkerParameters) : Worker(appCon
companion object {
const val KEY_BASE = "base"
const val KEY_URL = "url"
const val KEY_ENDPOINT_PATH = "endpoint_path"
private const val CHANNEL_ID = "relaytv_silent"
private const val NOTIF_ID = 4242
}
@@ -23,9 +23,10 @@ class ShareWorker(appContext: Context, params: WorkerParameters) : Worker(appCon
override fun doWork(): Result {
val base = inputData.getString(KEY_BASE)?.trim()?.trimEnd('/') ?: return Result.failure()
val url = inputData.getString(KEY_URL) ?: return Result.failure()
val endpointPath = inputData.getString(KEY_ENDPOINT_PATH)?.trim()?.ifBlank { "/smart" } ?: "/smart"
return try {
val payload = JSONObject().put("url", url).toString()
val req = Net.postJson(base + "/smart", payload)
val req = Net.postJson(base + endpointPath, payload)
Net.client.newCall(req).execute().use { resp ->
val body = resp.body?.string().orEmpty()
@@ -57,10 +58,8 @@ class ShareWorker(appContext: Context, params: WorkerParameters) : Worker(appCon
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 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)
@@ -78,7 +77,7 @@ class ShareWorker(appContext: Context, params: WorkerParameters) : Worker(appCon
applicationContext,
0,
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0)
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
builder.setContentIntent(pending)
}
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="120"
android:viewportHeight="120">
<path
android:fillColor="#FFFFFF"
android:pathData="M12.98,22.42v74.54c0,5.48 2.19,9.87 6.58,12.06 4.38,2.19 8.77,2.19 13.15,0l70.15,-37.27c4.38,-2.19 6.58,-6.58 6.58,-12.06 0,-5.48 -2.19,-9.87 -6.58,-12.06L32.71,10.36C23.47,5.74 12.98,12.99 12.98,22.42z" />
<path
android:fillColor="#1B2439"
android:pathData="M16.98,25.69v68c0,5 2,9 6,11 4,2 8,2 12,0l64,-34c4,-2 6,-6 6,-11 0,-5 -2,-9 -6,-11l-64,-34c-8.43,-4.22 -18,2.41 -18,11z" />
<path
android:fillColor="#00000000"
android:pathData="M64.89,39.13C61.32,36.63 38.94,24.09 34.01,21.83c-5.76,-2.64 -10.27,1.85 -9.88,5.88 0.17,1.77 0.18,57.55 0.21,63.09 0.04,8.03 5.25,8.1 8.46,7.16 5.14,-1.41 28.58,-15.18 29.63,-15.78"
android:strokeColor="#36D9F4"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="5" />
<path
android:fillColor="#00000000"
android:pathData="M66.28,72.15C65.1,71.98 52.37,69.37 50.4,69.24M67.69,50.59C65.1,51.84 49.2,61.51 49.2,61.51"
android:strokeColor="#2D89FF"
android:strokeLineCap="round"
android:strokeWidth="4" />
<path
android:fillColor="#2D89FF"
android:pathData="M73.01,66.99a6.97,6.97 0 1,1 0,13.93a6.97,6.97 0 1,1 0,-13.93z" />
<path
android:fillColor="#36D9F4"
android:pathData="M44.86,55.91a9.96,9.96 0 1,1 0,19.92a9.96,9.96 0 1,1 0,-19.92z" />
<path
android:fillColor="#2D89FF"
android:pathData="M74.24,41.12a7,7 0 1,1 0,14a7,7 0 1,1 0,-14z" />
</vector>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="120"
android:viewportHeight="120">
<path
android:fillColor="#00000000"
android:pathData="M16.98,25.69v68c0,5 2,9 6,11 4,2 8,2 12,0l64,-34c4,-2 6,-6 6,-11 0,-5 -2,-9 -6,-11l-64,-34c-8.43,-4.22 -18,2.41 -18,11z"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="6" />
<path
android:fillColor="#00000000"
android:pathData="M64.89,39.13C61.32,36.63 38.94,24.09 34.01,21.83c-5.76,-2.64 -10.27,1.85 -9.88,5.88 0.17,1.77 0.18,57.55 0.21,63.09 0.04,8.03 5.25,8.1 8.46,7.16 5.14,-1.41 28.58,-15.18 29.63,-15.78"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="5" />
<path
android:fillColor="#00000000"
android:pathData="M66.28,72.15C65.1,71.98 52.37,69.37 50.4,69.24M67.69,50.59C65.1,51.84 49.2,61.51 49.2,61.51"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"
android:strokeWidth="4" />
<path
android:fillColor="#FFFFFF"
android:pathData="M73.01,66.99a6.97,6.97 0 1,1 0,13.93a6.97,6.97 0 1,1 0,-13.93z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M44.86,55.91a9.96,9.96 0 1,1 0,19.92a9.96,9.96 0 1,1 0,-19.92z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M74.24,41.12a7,7 0 1,1 0,14a7,7 0 1,1 0,-14z" />
</vector>
+4 -11
View File
@@ -2,8 +2,7 @@
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/relaytv_surface">
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
@@ -18,16 +17,10 @@
app:menu="@menu/main_menu" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
<WebView
android:id="@+id/web"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<WebView
android:id="@+id/web"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -1,66 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="RelayTV Server"
android:textSize="22sp"
android:textStyle="bold"
android:paddingBottom="10dp"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Base URL (HTTP or HTTPS). Example: http://nuc.lan:8787"
android:textSize="14sp"
android:paddingBottom="8dp"/>
<EditText
android:id="@+id/editBaseUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="http://nuc.lan:8787"
android:inputType="textUri"
android:singleLine="true"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="14dp">
<Button
android:id="@+id/btnSave"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Save"/>
<Space
android:layout_width="12dp"
android:layout_height="wrap_content"/>
<Button
android:id="@+id/btnDetect"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Auto-detect"/>
</LinearLayout>
<TextView
android:id="@+id/txtStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textSize="13sp"
android:paddingTop="10dp"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Tip: Share a link from BravePipe → RelayTV. It sends to /smart in the background and shows a notification (tap it to open UI)."
android:textSize="13sp"
android:paddingTop="16dp"/>
</LinearLayout>
</ScrollView>
+4
View File
@@ -11,4 +11,8 @@
android:title="@string/reload"
android:icon="@android:drawable/ic_popup_sync"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_privacy"
android:title="@string/privacy_policy"
app:showAsAction="never" />
</menu>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/relaytv_surface" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/relaytv_surface" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
+7
View File
@@ -21,4 +21,11 @@
<string name="status_offline">Offline</string>
<string name="status_checking">Checking…</string>
<string name="status_active_prefix">Active</string>
<string name="share_queue_label">RelayTV Queue</string>
<string name="share_play_now_label">RelayTV Play</string>
<string name="share_queue_toast">Queued on "%s"</string>
<string name="share_play_now_toast">Playing now on "%s"</string>
<string name="privacy_policy">Privacy policy</string>
<string name="privacy_policy_url">https://github.com/mcgeezy/relaytv-android/blob/main/docs/PRIVACY_POLICY.md</string>
<string name="privacy_open_failed">No browser available to open the privacy policy.</string>
</resources>
-8
View File
@@ -10,12 +10,4 @@
</style>
<style name="Theme.RelayTV.NoActionBar" parent="Theme.Material3.DayNight.NoActionBar" />
<style name="Theme.RelayTV.Share" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:colorBackground">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="root" path="." />
</full-backup-content>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
</device-transfer>
</data-extraction-rules>
@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<base-config
cleartextTrafficPermitted="true"
tools:ignore="InsecureBaseConfiguration" />
</network-security-config>
+74
View File
@@ -0,0 +1,74 @@
# RelayTV Android Privacy Policy
Last updated: April 3, 2026
## Summary
RelayTV Android is a companion remote for self-hosted RelayTV servers. The app does not require an account, does not include advertising, and does not use third-party analytics SDKs.
## Information The App Processes
RelayTV Android may process the following categories of data in order to operate:
1. RelayTV server information
- Server names and base URLs you add in the app
- The currently selected active server
2. Shared content
- Links you share into the app using Android share targets such as `RelayTV Queue` and `RelayTV Play`
3. Local network discovery data
- RelayTV service advertisements discovered on your local network via mDNS / NSD
4. Device-local notification data
- Notification text used to confirm a queue or play action
## How The App Uses Data
RelayTV Android uses the data above only to:
1. Connect to the RelayTV server you selected
2. Load the RelayTV web UI in an embedded WebView
3. Send shared links to RelayTV endpoints such as `/smart` or `/play_now`
4. Discover compatible RelayTV servers on your local network
5. Show local notifications about share results and reconnect state
## Data Storage
The app stores configured server names and base URLs locally on your device using Android shared preferences.
The app does not operate its own cloud backend and does not sync your app data to a RelayTV-operated online service.
## Data Sharing
RelayTV Android sends requests only to the RelayTV server you configured or discovered and chose to use.
The app does not sell personal information and does not share data with advertising networks or analytics providers.
## Network Security
RelayTV is designed for local-first and self-hosted use. Because many self-hosted RelayTV installations run on trusted LAN HTTP endpoints, the app allows cleartext HTTP connections in addition to HTTPS. If your RelayTV server is exposed outside a trusted private network, you should use HTTPS.
## Permissions
RelayTV Android may request or use these permissions:
1. `INTERNET`
- Required to contact your RelayTV server
2. `ACCESS_NETWORK_STATE`
- Required to detect connectivity changes and recover the UI after network interruptions
3. `CHANGE_WIFI_MULTICAST_STATE`
- Required for local network RelayTV discovery on some devices
4. `POST_NOTIFICATIONS`
- Required on newer Android versions to show queue/play result notifications
## Children
RelayTV Android is not directed to children and does not knowingly collect personal information from children.
## Changes
This privacy policy may be updated when app behavior changes. Update the "Last updated" date whenever this document changes materially.
## Contact
For project updates or issue reporting, use the project repository:
https://github.com/mcgeezy/relaytv-android
+60
View File
@@ -0,0 +1,60 @@
# RelayTV Android Release Checklist
Use this checklist before cutting a public release or uploading a new Play Console build.
## Build Readiness
1. Verify Android toolchain prerequisites:
- `./scripts/check-android-env.sh all`
2. Install or refresh required SDK components:
- `./scripts/bootstrap-android.sh`
3. Validate debug build, lint, and unit tests:
- `./scripts/build-debug.sh`
4. Validate release bundle and release lint:
- `./scripts/build-release.sh`
5. If you have a device connected, run instrumentation tests:
- `./scripts/test-connected.sh`
## Versioning
1. Increment `versionCode` for every public upload.
2. Set `versionName` to the user-facing release number.
3. Verify the user agent string and release notes match the version being shipped.
## Play Console Requirements
1. Keep `targetSdk` at the current Google Play minimum.
- As of April 3, 2026, Play requires new apps and updates to target Android 15 / API 35.
2. Upload an Android App Bundle:
- `app/build/outputs/bundle/release/app-release.aab`
3. Use Play App Signing in Play Console.
4. Complete the Data safety form for this release.
5. Provide a valid support email in Play Console.
6. Publish a public privacy policy URL.
- This repo includes a draft policy in `docs/PRIVACY_POLICY.md`.
- The in-app privacy menu points to the GitHub-hosted copy on `main`.
## App-Specific Verification
1. Confirm manual server entry still works for both HTTP and HTTPS RelayTV servers.
2. Confirm LAN discovery finds `_relaytv._tcp` services on the local network.
3. Confirm both Android share targets work:
- `RelayTV Queue` -> `/smart`
- `RelayTV Play` -> `/play_now`
4. Confirm the WebView reconnects after the RelayTV server restarts or the network drops.
5. Confirm the privacy menu opens the published policy URL.
## Signing And Distribution
1. Configure your release signing key or Play App Signing flow.
2. Archive the signed `.aab`, release notes, and the exact commit SHA used for the build.
3. Tag the release in git after the Play upload is verified.
## Official References
1. Target API requirement:
- https://developer.android.com/google/play/requirements/target-sdk
2. App setup, versioning, and Play App Signing:
- https://support.google.com/googleplay/android-developer/answer/9859152
3. Developer support contact requirements:
- https://support.google.com/googleplay/android-developer/answer/113477
+1
View File
@@ -1,2 +1,3 @@
android.useAndroidX=true
android.enableJetifier=true
android.suppressUnsupportedCompileSdk=35
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<lint>
<issue id="GradleDependency" severity="ignore" />
<issue id="IconDuplicates" severity="ignore" />
<issue id="IconLauncherShape" severity="ignore" />
</lint>
+3 -3
View File
@@ -5,9 +5,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
INSTALL_EMULATOR="${INSTALL_EMULATOR:-0}"
ANDROID_PLATFORM="${ANDROID_PLATFORM:-android-34}"
ANDROID_BUILD_TOOLS="${ANDROID_BUILD_TOOLS:-34.0.0}"
ANDROID_SYSTEM_IMAGE="${ANDROID_SYSTEM_IMAGE:-system-images;android-34;google_apis;x86_64}"
ANDROID_PLATFORM="${ANDROID_PLATFORM:-android-35}"
ANDROID_BUILD_TOOLS="${ANDROID_BUILD_TOOLS:-35.0.0}"
ANDROID_SYSTEM_IMAGE="${ANDROID_SYSTEM_IMAGE:-system-images;android-35;google_apis;x86_64}"
say() { printf '%s\n' "$*"; }
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
if [[ ! -x "./scripts/check-android-env.sh" ]]; then
chmod +x "./scripts/check-android-env.sh"
fi
./scripts/check-android-env.sh build
if [[ ! -x "./gradlew" ]]; then
chmod +x ./gradlew
fi
if [[ "$#" -gt 0 ]]; then
TASKS=("$@")
else
TASKS=("clean" "bundleRelease" "lintRelease")
fi
echo "Running Gradle tasks: ${TASKS[*]}"
./gradlew --no-daemon "${TASKS[@]}"
+16
View File
@@ -7,6 +7,8 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
failures=0
warnings=0
ANDROID_PLATFORM="${ANDROID_PLATFORM:-android-35}"
ANDROID_BUILD_TOOLS="${ANDROID_BUILD_TOOLS:-35.0.0}"
say() { printf '%s\n' "$*"; }
ok() { say "[ok] $*"; }
@@ -90,6 +92,20 @@ check_sdk() {
else
warn "sdkmanager not found in PATH or ${sdk_root}/cmdline-tools/latest/bin."
fi
if [[ -n "${sdk_root}" ]]; then
if [[ -d "${sdk_root}/platforms/${ANDROID_PLATFORM}" ]]; then
ok "Found Android platform: ${ANDROID_PLATFORM}"
else
fail "Missing Android platform: ${ANDROID_PLATFORM}"
fi
if [[ -d "${sdk_root}/build-tools/${ANDROID_BUILD_TOOLS}" ]]; then
ok "Found Android build-tools: ${ANDROID_BUILD_TOOLS}"
else
fail "Missing Android build-tools: ${ANDROID_BUILD_TOOLS}"
fi
fi
}
check_gradle_wrapper() {