1 Commits

Author SHA1 Message Date
claude-code bdfb462eb9 Add phone pairing via QR code
The TV runs a small NanoHTTPD server on the LAN and shows a QR with a
one-time token; the phone opens a mobile form served by the TV and sends
the Immich server, email and password (or an API key). The TV signs in,
mints the scoped API key and stores it. Settings refresh on resume.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BvT3RjNpBmMUsyPqHq7fm
2026-09-11 20:00:25 +00:00
11 changed files with 337 additions and 2 deletions
+1
View File
@@ -9,6 +9,7 @@ Ambient photo screensaver for Android TV / Google TV that plays photos and video
- Date and location overlay, clock.
- Works as the **system screensaver** (Settings → Screen saver) and as a normal app (start the slideshow manually).
- Filter by albums, favorites, photos / videos.
- **Set up from your phone**: the TV shows a QR code, the phone opens a form served by the TV on the LAN and sends the credentials; no typing with the remote.
- Sign in with email + password once: the app creates a scoped API key (`asset.read`, `asset.view`, `album.read`, `user.read`) and never stores the password.
Tested against Immich 3.1. Requires Android 8.0+ (API 26).
+4 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "cloud.alexandrevazquez.immichtv"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
versionCode = 2
versionName = "1.1.0"
}
signingConfigs {
@@ -60,4 +60,6 @@ dependencies {
implementation("androidx.media3:media3-exoplayer:1.4.1")
implementation("androidx.media3:media3-ui:1.4.1")
implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
implementation("com.google.zxing:core:3.5.3")
implementation("org.nanohttpd:nanohttpd:2.3.1")
}
+4
View File
@@ -25,6 +25,10 @@
</intent-filter>
</activity>
<activity
android:name=".ui.PairActivity"
android:exported="false" />
<activity
android:name=".ui.SlideshowActivity"
android:exported="true"
@@ -0,0 +1,111 @@
package cloud.alexandrevazquez.immichtv.ui
import android.graphics.Bitmap
import android.graphics.Color
import android.os.Bundle
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import cloud.alexandrevazquez.immichtv.App
import cloud.alexandrevazquez.immichtv.R
import cloud.alexandrevazquez.immichtv.data.ImmichApi
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.IOException
import java.security.SecureRandom
/**
* Shows a QR code the phone can scan to configure the server from a proper keyboard.
* The phone talks to a small web server running on the TV, on the local network only.
*/
class PairActivity : ComponentActivity() {
private var server: PairServer? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_pair)
}
override fun onStart() {
super.onStart()
startServer()
}
override fun onStop() {
server?.stop()
server = null
super.onStop()
}
private fun startServer() {
val status = findViewById<TextView>(R.id.pair_status)
val urlText = findViewById<TextView>(R.id.pair_url)
val qr = findViewById<ImageView>(R.id.pair_qr)
val ip = PairServer.lanAddress()
if (ip == null) {
status.text = getString(R.string.pair_no_network)
return
}
val token = SecureRandom().let { rnd -> (1..10).map { "abcdefghjkmnpqrstuvwxyz23456789"[rnd.nextInt(31)] }.joinToString("") }
val app = App.from(this)
val initial = app.settings.serverUrl
val onSubmit: (String, String, String, String) -> String = { serverUrl, email, password, apiKey ->
runBlocking {
val base = ImmichApi.normalizeUrl(serverUrl)
if (base.isBlank()) throw IOException(getString(R.string.toast_enter_server_first))
val key = if (apiKey.isNotBlank()) apiKey.trim()
else ImmichApi.createApiKeyWithPassword(base, email, password, "Android TV (${android.os.Build.MODEL})")
val name = ImmichApi(base, key).me()
app.settings.setServer(base, key)
runOnUiThread { onPaired(name) }
name
}
}
var started: PairServer? = null
for (port in listOf(8765, 8766, 8767, 0)) {
try {
started = PairServer(this, port, token, initial, onSubmit).also { it.start(fi.iki.elonen.NanoHTTPD.SOCKET_READ_TIMEOUT, false) }
break
} catch (_: IOException) {
}
}
val srv = started
if (srv == null) {
status.text = getString(R.string.pair_server_error)
return
}
server = srv
val url = "http://$ip:${srv.listeningPort}/?t=$token"
urlText.text = url
qr.setImageBitmap(qrBitmap(url, resources.getDimensionPixelSize(R.dimen.pair_qr_size)))
status.text = getString(R.string.pair_waiting)
}
private fun onPaired(name: String) {
findViewById<TextView>(R.id.pair_status).text = getString(R.string.toast_connected_as, name)
findViewById<ImageView>(R.id.pair_qr).alpha = 0.25f
setResult(RESULT_OK)
lifecycleScope.launch {
delay(2500)
finish()
}
}
private fun qrBitmap(content: String, size: Int): Bitmap {
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, mapOf(EncodeHintType.MARGIN to 1))
val pixels = IntArray(size * size)
for (y in 0 until size) for (x in 0 until size) {
pixels[y * size + x] = if (matrix[x, y]) Color.BLACK else Color.WHITE
}
return Bitmap.createBitmap(pixels, size, size, Bitmap.Config.RGB_565)
}
}
@@ -0,0 +1,109 @@
package cloud.alexandrevazquez.immichtv.ui
import android.content.Context
import cloud.alexandrevazquez.immichtv.R
import fi.iki.elonen.NanoHTTPD
import java.net.Inet4Address
import java.net.NetworkInterface
/**
* Tiny LAN web server used for pairing: the TV shows a QR with this server's URL, the phone
* opens a mobile-friendly form and posts the Immich credentials back to the TV.
* Only requests carrying the one-time [token] are accepted.
*/
class PairServer(
private val context: Context,
port: Int,
private val token: String,
private val initialServer: String,
/** Performs the login on the TV side. Returns the display name on success, throws on failure. */
private val onSubmit: (server: String, email: String, password: String, apiKey: String) -> String,
) : NanoHTTPD(port) {
override fun serve(session: IHTTPSession): Response {
if (session.method == Method.POST) {
session.parseBody(HashMap())
}
val given = session.parameters["t"]?.firstOrNull() ?: session.parameters["token"]?.firstOrNull()
if (given != token) {
return newFixedLengthResponse(Response.Status.FORBIDDEN, "text/plain", "Invalid pairing token")
}
return when {
session.method == Method.POST && session.uri == "/pair" -> {
val p = session.parameters
val server = p["server"]?.firstOrNull().orEmpty()
val email = p["email"]?.firstOrNull().orEmpty()
val password = p["password"]?.firstOrNull().orEmpty()
val apiKey = p["api_key"]?.firstOrNull().orEmpty()
try {
val name = onSubmit(server, email, password, apiKey)
html(page(success = context.getString(R.string.pair_web_success, name), server = server))
} catch (e: Exception) {
html(page(error = e.message ?: e.javaClass.simpleName, server = server, email = email))
}
}
else -> html(page(server = initialServer))
}
}
private fun html(body: String): Response = newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", body)
private fun esc(s: String): String = s.replace("&", "&amp;").replace("<", "&lt;").replace("\"", "&quot;")
private fun page(server: String = "", email: String = "", error: String? = null, success: String? = null): String {
val c = context
val title = c.getString(R.string.app_name)
val banner = when {
success != null -> """<div class="ok">${esc(success)}</div><p class="muted">${esc(c.getString(R.string.pair_web_done_hint))}</p>"""
error != null -> """<div class="err">${esc(error)}</div>"""
else -> ""
}
val form = if (success != null) "" else """
<form method="post" action="/pair">
<input type="hidden" name="t" value="${esc(token)}">
<label>${esc(c.getString(R.string.pref_server_url))}
<input name="server" type="url" inputmode="url" autocapitalize="none" autocorrect="off"
placeholder="https://immich.example.com" value="${esc(server)}" required></label>
<label>${esc(c.getString(R.string.hint_email))}
<input name="email" type="email" inputmode="email" autocomplete="username" autocapitalize="none" value="${esc(email)}"></label>
<label>${esc(c.getString(R.string.hint_password))}
<input name="password" type="password" autocomplete="current-password"></label>
<details><summary>${esc(c.getString(R.string.pair_web_or_api_key))}</summary>
<label>${esc(c.getString(R.string.pref_api_key))}
<input name="api_key" type="text" autocapitalize="none" autocorrect="off" spellcheck="false"></label>
</details>
<button type="submit">${esc(c.getString(R.string.pair_web_submit))}</button>
</form>
<p class="muted">${esc(c.getString(R.string.pair_web_privacy))}</p>
"""
return """<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(title)}</title>
<style>
body{font-family:-apple-system,Roboto,sans-serif;background:#101418;color:#eee;margin:0;padding:24px;max-width:480px;margin:auto}
h1{font-size:22px;margin:8px 0 16px}
label{display:block;margin:14px 0 6px;font-size:14px;color:#bbb}
input{width:100%;box-sizing:border-box;font-size:18px;padding:12px;border-radius:8px;border:1px solid #444;background:#1c2128;color:#fff;margin-top:6px}
button{width:100%;margin-top:22px;font-size:18px;padding:14px;border:0;border-radius:8px;background:#4250af;color:#fff}
details{margin-top:14px} summary{color:#9ab;cursor:pointer}
.ok{background:#1f5f3a;padding:14px;border-radius:8px;font-size:18px}
.err{background:#6b2323;padding:14px;border-radius:8px;margin-bottom:10px}
.muted{color:#888;font-size:13px}
</style></head><body>
<h1>📺 ${esc(title)}</h1>
$banner
$form
</body></html>"""
}
companion object {
/** First site-local IPv4 address (Wi-Fi or Ethernet) of this device, or null. */
fun lanAddress(): String? = runCatching {
NetworkInterface.getNetworkInterfaces().toList()
.filter { it.isUp && !it.isLoopback }
.flatMap { it.inetAddresses.toList() }
.filterIsInstance<Inet4Address>()
.firstOrNull { it.isSiteLocalAddress }
?.hostAddress
}.getOrNull()
}
}
@@ -63,6 +63,9 @@ class SettingsFragment : LeanbackSettingsFragmentCompat() {
if (p.text.isNullOrBlank()) getString(R.string.summary_not_set) else getString(R.string.summary_api_key_set)
}
}
findPreference<Preference>("pair")?.setOnPreferenceClickListener {
startActivity(Intent(requireContext(), PairActivity::class.java)); true
}
findPreference<Preference>("login")?.setOnPreferenceClickListener { showLoginDialog(); true }
findPreference<Preference>("test_connection")?.setOnPreferenceClickListener { testConnection(); true }
findPreference<Preference>("preview")?.setOnPreferenceClickListener {
@@ -73,6 +76,21 @@ class SettingsFragment : LeanbackSettingsFragmentCompat() {
findPreference<MultiSelectListPreference>(Settings.KEY_ALBUMS)?.let { loadAlbums(it) }
}
private var lastServer: String? = null
/** Settings may have been written by PairActivity or an adb intent while we were away. */
override fun onResume() {
super.onResume()
val settings = app.settings
findPreference<EditTextPreference>(Settings.KEY_SERVER_URL)?.text = settings.serverUrl
findPreference<EditTextPreference>(Settings.KEY_API_KEY)?.text = settings.apiKey
val key = settings.serverUrl + "|" + settings.apiKey
if (lastServer != null && lastServer != key) {
findPreference<MultiSelectListPreference>(Settings.KEY_ALBUMS)?.let { loadAlbums(it) }
}
lastServer = key
}
private fun loadAlbums(pref: MultiSelectListPreference) {
val api = app.api()
if (api == null) {
+58
View File
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/settings_background"
android:gravity="center"
android:orientation="horizontal"
android:padding="48dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:paddingEnd="48dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pair_title"
android:textColor="#FFFFFF"
android:textSize="32sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:lineSpacingMultiplier="1.2"
android:text="@string/pair_instructions"
android:textColor="#CCCCCC"
android:textSize="18sp" />
<TextView
android:id="@+id/pair_url"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:fontFamily="monospace"
android:textColor="#9AB"
android:textSize="16sp" />
<TextView
android:id="@+id/pair_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:textColor="#FFFFFF"
android:textSize="20sp" />
</LinearLayout>
<ImageView
android:id="@+id/pair_qr"
android:layout_width="@dimen/pair_qr_size"
android:layout_height="@dimen/pair_qr_size"
android:background="#FFFFFF"
android:contentDescription="@string/pair_title"
android:padding="8dp" />
</LinearLayout>
+12
View File
@@ -71,4 +71,16 @@
<string name="status_not_configured">Immich no está configurado.\nAbre la app Immich Screensaver para configurar el servidor.</string>
<string name="status_loading">Cargando fotos…</string>
<string name="pref_pair">Configurar desde el móvil (código QR)</string>
<string name="pref_pair_summary">Escanea un código y escribe las credenciales en el móvil</string>
<string name="pair_title">Configurar desde el móvil</string>
<string name="pair_instructions">1. El móvil tiene que estar en la misma Wi-Fi que la TV.\n2. Escanea el código con la cámara del móvil.\n3. Introduce en el móvil tu servidor Immich, email y contraseña.</string>
<string name="pair_waiting">Esperando al móvil…</string>
<string name="pair_no_network">No se ha encontrado dirección de red local. Conecta la TV a la Wi-Fi o por cable.</string>
<string name="pair_server_error">No se pudo iniciar el servidor de emparejamiento.</string>
<string name="pair_web_submit">Conectar la TV</string>
<string name="pair_web_or_api_key">…o pega una API key en su lugar</string>
<string name="pair_web_success">¡Listo! Conectado como %1$s.</string>
<string name="pair_web_done_hint">Puedes cerrar esta página. La TV ya está configurada.</string>
<string name="pair_web_privacy">Las credenciales van directamente a tu TV por la red local. La TV inicia sesión en Immich y solo guarda una API key.</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="pair_qr_size">360dp</dimen>
</resources>
+12
View File
@@ -71,4 +71,16 @@
<string name="status_not_configured">Immich is not configured.\nOpen the Immich Screensaver app to set up your server.</string>
<string name="status_loading">Loading photos…</string>
<string name="pref_pair">Set up from your phone (QR code)</string>
<string name="pref_pair_summary">Scan a code and type the credentials on the phone</string>
<string name="pair_title">Set up from your phone</string>
<string name="pair_instructions">1. Make sure the phone is on the same Wi-Fi as the TV.\n2. Scan the code with the phone camera.\n3. Enter your Immich server, email and password on the phone.</string>
<string name="pair_waiting">Waiting for the phone…</string>
<string name="pair_no_network">No local network address found. Connect the TV to Wi-Fi or Ethernet.</string>
<string name="pair_server_error">Could not start the pairing server.</string>
<string name="pair_web_submit">Connect TV</string>
<string name="pair_web_or_api_key">…or paste an API key instead</string>
<string name="pair_web_success">Done! Connected as %1$s.</string>
<string name="pair_web_done_hint">You can close this page. The TV is now configured.</string>
<string name="pair_web_privacy">The credentials go straight to your TV over the local network. The TV signs in to Immich and keeps only an API key.</string>
</resources>
+4
View File
@@ -4,6 +4,10 @@
android:title="@string/app_name">
<PreferenceCategory android:title="@string/cat_server">
<Preference
android:key="pair"
android:title="@string/pref_pair"
android:summary="@string/pref_pair_summary" />
<EditTextPreference
android:key="server_url"
android:title="@string/pref_server_url"