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
+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>