Merge branch 'main' into fix/fullscreen-widgets

This commit is contained in:
ScreenTinker 2026-06-08 22:42:59 -05:00
commit 5c0721b77f
5 changed files with 208 additions and 105 deletions

View file

@ -17,7 +17,7 @@
<application <application
android:name=".RemoteDisplayApp" android:name=".RemoteDisplayApp"
android:allowBackup="true" android:allowBackup="false"
android:icon="@android:drawable/ic_media_play" android:icon="@android:drawable/ic_media_play"
android:label="RemoteDisplay" android:label="RemoteDisplay"
android:largeHeap="true" android:largeHeap="true"

View file

@ -34,6 +34,7 @@ class ProvisioningActivity : AppCompatActivity() {
private lateinit var statusText: TextView private lateinit var statusText: TextView
private lateinit var progressBar: ProgressBar private lateinit var progressBar: ProgressBar
private lateinit var pairingSection: View private lateinit var pairingSection: View
private lateinit var serverSection: View
private val connection = object : ServiceConnection { private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
@ -73,6 +74,7 @@ class ProvisioningActivity : AppCompatActivity() {
statusText = findViewById(R.id.statusText) statusText = findViewById(R.id.statusText)
progressBar = findViewById(R.id.progressBar) progressBar = findViewById(R.id.progressBar)
pairingSection = findViewById(R.id.pairingSection) pairingSection = findViewById(R.id.pairingSection)
serverSection = findViewById(R.id.serverSection)
// Pre-fill if previously entered // Pre-fill if previously entered
if (config.serverUrl.isNotEmpty()) { if (config.serverUrl.isNotEmpty()) {
@ -135,9 +137,15 @@ class ProvisioningActivity : AppCompatActivity() {
wsService?.onRegistered = { deviceId -> wsService?.onRegistered = { deviceId ->
runOnUiThread { runOnUiThread {
progressBar.visibility = View.GONE progressBar.visibility = View.GONE
// Hide the server/connect controls so the pairing code has the
// whole screen and stays visible on short/landscape phones.
serverSection.visibility = View.GONE
connectBtn.visibility = View.GONE
pairingSection.visibility = View.VISIBLE pairingSection.visibility = View.VISIBLE
pairingCodeText.text = wsService?.getPairingCode() ?: "------" pairingCodeText.text = wsService?.getPairingCode() ?: "------"
statusText.text = "Enter this code in the dashboard to pair this display" // The instruction is shown once, inside the pairing section; don't
// duplicate it in statusText.
statusText.text = ""
connectBtn.isEnabled = false connectBtn.isEnabled = false
} }
} }

View file

@ -5,6 +5,9 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.Signature
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
@ -17,6 +20,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
import java.security.MessageDigest
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class UpdateChecker(private val context: Context) { class UpdateChecker(private val context: Context) {
@ -107,6 +111,20 @@ class UpdateChecker(private val context: Context) {
Log.i(TAG, "APK downloaded: ${apkFile.absolutePath} (${apkFile.length()} bytes)") Log.i(TAG, "APK downloaded: ${apkFile.absolutePath} (${apkFile.length()} bytes)")
// SECURITY (#5 review): never install an APK we didn't sign. The update
// is fetched from a server-supplied URL, often over cleartext with no
// pinning - a MITM or compromised server could otherwise return a
// malicious APK and get it silently installed (REQUEST_INSTALL_PACKAGES).
// Verify the downloaded APK is our package AND signed by the same key as
// the currently-installed app before installing. An attacker can't forge
// our signature, so this holds even over an untrusted transport.
if (!verifyApkSignature(apkFile)) {
Log.e(TAG, "Refusing update: APK signature/package verification failed (tampered or MITM'd APK)")
apkFile.delete()
return
}
Log.i(TAG, "APK signature verified against installed app - proceeding to install")
// Install the APK // Install the APK
handler.post { handler.post {
installApk(apkFile) installApk(apkFile)
@ -179,6 +197,56 @@ class UpdateChecker(private val context: Context) {
} }
} }
// True only if the downloaded APK is this same package and shares a signing
// certificate with the installed app. Fail-closed on any error.
private fun verifyApkSignature(apkFile: File): Boolean {
return try {
val pm = context.packageManager
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
val downloaded = pm.getPackageArchiveInfo(apkFile.absolutePath, flags)
if (downloaded == null) {
Log.e(TAG, "Could not parse downloaded APK")
return false
}
if (downloaded.packageName != context.packageName) {
Log.e(TAG, "APK package mismatch: ${downloaded.packageName} != ${context.packageName}")
return false
}
val installed = pm.getPackageInfo(context.packageName, flags)
val downloadedSigs = signingCertHashes(downloaded)
val installedSigs = signingCertHashes(installed)
if (downloadedSigs.isEmpty() || installedSigs.isEmpty()) {
Log.e(TAG, "Missing signing certificates (downloaded=${downloadedSigs.size}, installed=${installedSigs.size})")
return false
}
// Share at least one current signing certificate.
val match = downloadedSigs.any { it in installedSigs }
if (!match) Log.e(TAG, "APK signing certificate does not match installed app")
match
} catch (e: Exception) {
Log.e(TAG, "Signature verification error: ${e.message}", e)
false
}
}
private fun signingCertHashes(info: PackageInfo): Set<String> {
val sigs: Array<Signature>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.signingInfo?.apkContentsSigners
} else {
@Suppress("DEPRECATION") info.signatures
}
return sigs?.mapNotNull { sha256(it.toByteArray()) }?.toSet() ?: emptySet()
}
private fun sha256(bytes: ByteArray): String? {
return try {
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
null
}
}
private fun getAppVersion(): String { private fun getAppVersion(): String {
return try { return try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0" context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0"

View file

@ -1,123 +1,145 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <!-- Scrolls so content is always reachable on short screens; the pairing code
auto-sizes to fit any screen width (phones, TVs, HD sticks). -->
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android" 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_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:gravity="center" android:fillViewport="true"
android:orientation="vertical" android:background="#111827">
android:background="#111827"
android:padding="48dp"
android:keepScreenOn="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="RemoteDisplay"
android:textColor="#3B82F6"
android:textSize="36sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Digital Signage Player"
android:textColor="#94A3B8"
android:textSize="16sp"
android:layout_marginBottom="48dp" />
<!-- Server URL Section -->
<LinearLayout <LinearLayout
android:layout_width="400dp" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center_horizontal|top"
android:orientation="vertical" android:orientation="vertical"
android:layout_marginBottom="24dp"> android:paddingHorizontal="32dp"
android:paddingTop="24dp"
android:paddingBottom="24dp"
android:keepScreenOn="true">
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Server URL" android:text="RemoteDisplay"
android:textColor="#94A3B8"
android:textSize="14sp"
android:layout_marginBottom="8dp" />
<EditText
android:id="@+id/serverUrlInput"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="@drawable/input_background"
android:hint="http://192.168.1.100:3000"
android:textColorHint="#64748B"
android:textColor="#F1F5F9"
android:textSize="16sp"
android:padding="12dp"
android:inputType="textUri"
android:singleLine="true"
android:importantForAutofill="no" />
</LinearLayout>
<Button
android:id="@+id/connectBtn"
android:layout_width="400dp"
android:layout_height="48dp"
android:text="Connect"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold"
android:background="@drawable/button_primary"
android:layout_marginBottom="32dp" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="48dp"
android:layout_height="48dp"
android:indeterminate="true"
android:visibility="gone" />
<!-- Pairing Section (shown after connection) -->
<LinearLayout
android:id="@+id/pairingSection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Pairing Code"
android:textColor="#94A3B8"
android:textSize="16sp"
android:layout_marginBottom="12dp" />
<TextView
android:id="@+id/pairingCodeText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="------"
android:textColor="#3B82F6" android:textColor="#3B82F6"
android:textSize="64sp" android:textSize="22sp"
android:textStyle="bold" android:textStyle="bold"
android:fontFamily="monospace" android:layout_marginBottom="4dp" />
android:letterSpacing="0.3" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Enter this code in the dashboard to pair this display" android:text="Digital Signage Player"
android:textColor="#64748B" android:textColor="#94A3B8"
android:textSize="14sp" android:textSize="14sp"
android:layout_marginTop="16dp" android:layout_marginBottom="20dp" />
android:gravity="center" />
<!-- Server URL Section (hidden once paired so the code has room) -->
<LinearLayout
android:id="@+id/serverSection"
android:layout_width="400dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Server URL"
android:textColor="#94A3B8"
android:textSize="14sp"
android:layout_marginBottom="8dp" />
<EditText
android:id="@+id/serverUrlInput"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="@drawable/input_background"
android:hint="http://192.168.1.100:3000"
android:textColorHint="#64748B"
android:textColor="#F1F5F9"
android:textSize="16sp"
android:padding="12dp"
android:inputType="textUri"
android:singleLine="true"
android:importantForAutofill="no" />
</LinearLayout>
<Button
android:id="@+id/connectBtn"
android:layout_width="400dp"
android:layout_height="48dp"
android:text="Connect"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold"
android:background="@drawable/button_primary"
android:layout_marginBottom="32dp" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="48dp"
android:layout_height="48dp"
android:indeterminate="true"
android:visibility="gone" />
<!-- Pairing Section (shown after connection) -->
<LinearLayout
android:id="@+id/pairingSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Pairing Code"
android:textColor="#94A3B8"
android:textSize="16sp"
android:layout_marginBottom="12dp" />
<!-- FIXED-height box: autosize fits the text inside the bounded box and
gravity center vertically centers it, so the digits are never
clipped (the earlier wrap_content height clipped the glyph bottoms).
24-64sp fills the width on phones/TVs/sticks. -->
<TextView
android:id="@+id/pairingCodeText"
android:layout_width="match_parent"
android:layout_height="96dp"
android:text="------"
android:textColor="#3B82F6"
android:textStyle="bold"
android:fontFamily="monospace"
android:letterSpacing="0.3"
android:maxLines="1"
android:gravity="center"
app:autoSizeTextType="uniform"
app:autoSizeMinTextSize="24sp"
app:autoSizeMaxTextSize="64sp"
app:autoSizeStepGranularity="2sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Enter this code in the dashboard to pair this display"
android:textColor="#64748B"
android:textSize="14sp"
android:layout_marginTop="16dp"
android:gravity="center" />
</LinearLayout>
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#94A3B8"
android:textSize="14sp"
android:gravity="center"
android:layout_marginTop="16dp" />
</LinearLayout> </LinearLayout>
</ScrollView>
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#94A3B8"
android:textSize="14sp"
android:layout_marginTop="16dp" />
</LinearLayout>

View file

@ -212,6 +212,11 @@ body {
.nav-links { .nav-links {
flex: 1; flex: 1;
padding: 12px 8px; padding: 12px 8px;
/* Scroll the nav when it's taller than the viewport (short screens, e.g.
1366x768) so items below the fold (Settings) stay reachable. min-height:0
is required for a flex child to shrink and scroll instead of overflowing. */
overflow-y: auto;
min-height: 0;
} }
.nav-link { .nav-link {