Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bdfb462eb9 |
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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("&", "&").replace("<", "<").replace("\"", """)
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<dimen name="pair_qr_size">360dp</dimen>
|
||||
</resources>
|
||||
@@ -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,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"
|
||||
|
||||
Reference in New Issue
Block a user