From e8be7a4fc2b8dcfe2ea73af3d932a7a030cf5455 Mon Sep 17 00:00:00 2001 From: mcgeezy Date: Fri, 3 Apr 2026 16:37:23 -0500 Subject: [PATCH] chore: prepare Android app for release --- README.md | 229 +++++------------- app/build.gradle | 34 +-- app/src/main/AndroidManifest.xml | 50 +++- app/src/main/java/pro/relaytv/MainActivity.kt | 46 ++-- .../main/java/pro/relaytv/ShareActivity.kt | 21 +- app/src/main/java/pro/relaytv/ShareWorker.kt | 13 +- .../res/drawable/ic_launcher_foreground.xml | 35 +++ .../res/drawable/ic_launcher_monochrome.xml | 36 +++ app/src/main/res/layout/activity_main.xml | 15 +- app/src/main/res/layout/activity_settings.xml | 66 ----- app/src/main/res/menu/main_menu.xml | 4 + .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + app/src/main/res/values/strings.xml | 7 + app/src/main/res/values/styles.xml | 8 - app/src/main/res/xml/backup_rules.xml | 4 + .../main/res/xml/data_extraction_rules.xml | 9 + .../main/res/xml/network_security_config.xml | 6 +- docs/PRIVACY_POLICY.md | 74 ++++++ docs/RELEASE_CHECKLIST.md | 60 +++++ gradle.properties | 1 + lint.xml | 6 + scripts/bootstrap-android.sh | 6 +- scripts/build-release.sh | 25 ++ scripts/check-android-env.sh | 16 ++ 25 files changed, 475 insertions(+), 308 deletions(-) create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_launcher_monochrome.xml delete mode 100644 app/src/main/res/layout/activity_settings.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 docs/PRIVACY_POLICY.md create mode 100644 docs/RELEASE_CHECKLIST.md create mode 100644 lint.xml create mode 100755 scripts/build-release.sh 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 @@ - - - - - - - -