diff --git a/README.md b/README.md
index 1318a1c..50621d1 100644
--- a/README.md
+++ b/README.md
@@ -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 RelayTVβs 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
diff --git a/app/build.gradle b/app/build.gradle
index 3d98d77..299479e 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f433697..af1ec07 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,7 +6,9 @@
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
{
+ 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()
diff --git a/app/src/main/java/pro/relaytv/ShareActivity.kt b/app/src/main/java/pro/relaytv/ShareActivity.kt
index 569c9ab..c933396 100644
--- a/app/src/main/java/pro/relaytv/ShareActivity.kt
+++ b/app/src/main/java/pro/relaytv/ShareActivity.kt
@@ -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()
@@ -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(')', ']', '>', '"', '\'')
diff --git a/app/src/main/java/pro/relaytv/ShareWorker.kt b/app/src/main/java/pro/relaytv/ShareWorker.kt
index faba922..dc14b55 100644
--- a/app/src/main/java/pro/relaytv/ShareWorker.kt
+++ b/app/src/main/java/pro/relaytv/ShareWorker.kt
@@ -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)
}
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..c727d25
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml
new file mode 100644
index 0000000..618888b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 6bc9664..8845bba 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -2,8 +2,7 @@
+ android:layout_height="match_parent">
-
-
-
-
+ app:layout_behavior="@string/appbar_scrolling_view_behavior" />
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
deleted file mode 100644
index 8830def..0000000
--- a/app/src/main/res/layout/activity_settings.xml
+++ /dev/null
@@ -1,66 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml
index 9865072..c539af2 100644
--- a/app/src/main/res/menu/main_menu.xml
+++ b/app/src/main/res/menu/main_menu.xml
@@ -11,4 +11,8 @@
android:title="@string/reload"
android:icon="@android:drawable/ic_popup_sync"
app:showAsAction="ifRoom" />
+
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 0000000..689b8ff
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
new file mode 100644
index 0000000..689b8ff
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6b58f9e..66927a3 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -21,4 +21,11 @@
Offline
Checkingβ¦
Active
+ RelayTV Queue
+ RelayTV Play
+ Queued on "%s"
+ Playing now on "%s"
+ Privacy policy
+ https://github.com/mcgeezy/relaytv-android/blob/main/docs/PRIVACY_POLICY.md
+ No browser available to open the privacy policy.
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 06ae325..4be63ad 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -10,12 +10,4 @@
-
-
-
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..24bad93
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..2130cfd
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
index 2439f15..28d358b 100644
--- a/app/src/main/res/xml/network_security_config.xml
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -1,4 +1,6 @@
-
-
+
+
diff --git a/docs/PRIVACY_POLICY.md b/docs/PRIVACY_POLICY.md
new file mode 100644
index 0000000..3df417c
--- /dev/null
+++ b/docs/PRIVACY_POLICY.md
@@ -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
diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md
new file mode 100644
index 0000000..722e9bc
--- /dev/null
+++ b/docs/RELEASE_CHECKLIST.md
@@ -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
diff --git a/gradle.properties b/gradle.properties
index 646c51b..b67373a 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,2 +1,3 @@
android.useAndroidX=true
android.enableJetifier=true
+android.suppressUnsupportedCompileSdk=35
diff --git a/lint.xml b/lint.xml
new file mode 100644
index 0000000..03510f7
--- /dev/null
+++ b/lint.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/scripts/bootstrap-android.sh b/scripts/bootstrap-android.sh
index cc0de4c..90ca951 100755
--- a/scripts/bootstrap-android.sh
+++ b/scripts/bootstrap-android.sh
@@ -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' "$*"; }
diff --git a/scripts/build-release.sh b/scripts/build-release.sh
new file mode 100755
index 0000000..a6c53bc
--- /dev/null
+++ b/scripts/build-release.sh
@@ -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[@]}"
diff --git a/scripts/check-android-env.sh b/scripts/check-android-env.sh
index 0c43a2a..c2b17bf 100755
--- a/scripts/check-android-env.sh
+++ b/scripts/check-android-env.sh
@@ -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() {