diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 3d40cb7b9..7201bd957 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -207,6 +207,9 @@ dependencies { implementation(libs.jsoup) + // qr code generation for the login on TV + implementation(libs.zxing.core) + implementation(libs.android.work) implementation(libs.countly) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/LocalTokenServer.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/LocalTokenServer.kt new file mode 100644 index 000000000..077b9d06c --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/LocalTokenServer.kt @@ -0,0 +1,348 @@ +package com.github.livingwithhippos.unchained.authentication + +import java.io.BufferedReader +import java.io.IOException +import java.net.Inet4Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketTimeoutException +import java.net.URLDecoder +import java.security.MessageDigest +import java.security.SecureRandom +import kotlin.concurrent.thread +import timber.log.Timber + +/** + * Minimal, temporary HTTP server used on Android TV to receive a single text value from another + * device on the same local network (e.g. the user's phone), since typing with a remote is painful. + * The authentication screen uses it for the private Real-Debrid token and the new download screen + * for a link or magnet. It serves a single form page and accepts one valid submission, after which + * it stops itself. It must also be stopped when the screen that started it is left. + * + * Security model, following what LocalSend does for its browser flows and what went wrong with + * always-on unauthenticated servers like the ES File Explorer one (CVE-2019-6447): + * - it only runs while its screen is visible, and stops itself after [SERVER_LIFETIME_MS] anyway + * - it binds only to the local network interface, never to all the interfaces + * - submissions must include the random [pin] displayed on the TV, so neither another host on the + * network nor a malicious web page loaded on one (CSRF/DNS rebinding) can plant its own value + * - it stops after [MAX_PIN_FAILURES] wrong PINs or after the first valid submission + * - it never sends any data out except the static form page + * + * The value travels in plain http on the local network, which is acceptable for a short lived, + * PIN protected server: TLS would require a self signed certificate that phone browsers refuse. + * + * @param pages localized texts used to build the served web pages + * @param isValueValid decides whether a submitted value is acceptable; rejected values get the + * error page and the server keeps waiting + * @param onValueReceived called with the submitted value, from a background thread + * @param onStopped called when the server stops itself (timeout, too many wrong PINs or value + * received), from a background thread. Not called by [stop]. + */ +class LocalTokenServer( + private val pages: Pages, + private val isValueValid: (String) -> Boolean, + private val onValueReceived: (String) -> Unit, + private val onStopped: () -> Unit = {}, + /** The PIN that must be typed in the served form, to be displayed on the TV */ + val pin: String = generatePin(), + /** + * Whether the PIN can be skipped, decided once by the caller before this server even starts + * (see the phone input session), rather than derived from anything the client sends per + * request such as a cookie: a phone's cookie jar is not something this can rely on, since each + * phone input starts a fresh server at a fresh address, and depending on the browser or QR + * scanner preview used to open it, an earlier cookie might not carry over. + */ + private val trusted: Boolean = false, + /** Called after a first valid PIN submission, so the caller can trust later phone inputs too. */ + private val onTrustEstablished: () -> Unit = {}, +) { + + /** + * Localized texts for the served pages. When [linkUrl] and [linkLabel] are set, a plain link + * is shown above the form, e.g. the Real-Debrid token page for the authentication flow. + */ + data class Pages( + val title: String, + val fieldLabel: String, + val pinLabel: String, + val submitLabel: String, + val successMessage: String, + val errorMessage: String, + val wrongPinMessage: String, + val linkUrl: String? = null, + val linkLabel: String? = null, + ) + + private var serverSocket: ServerSocket? = null + + /** + * Bind the first free port in [PORT_RANGE] on the local network interface and start serving. + * + * @return the reachable http address, or null if no local network address or free port was + * found + */ + fun start(): String? { + val address = findSiteLocalAddress() ?: return null + val socket = bindFirstFreePort(address) ?: return null + socket.soTimeout = ACCEPT_TIMEOUT_MS + serverSocket = socket + thread(isDaemon = true, name = "unchained-token-server") { serve(socket) } + return "http://${address.hostAddress}:${socket.localPort}" + } + + fun stop() { + try { + serverSocket?.close() + } catch (e: IOException) { + Timber.w(e, "Error closing the token server socket") + } + serverSocket = null + } + + private fun serve(socket: ServerSocket) { + val deadline = System.currentTimeMillis() + SERVER_LIFETIME_MS + var pinFailures = 0 + try { + while (!socket.isClosed) { + val result = + try { + socket.accept().use { client -> handleClient(client) } + } catch (e: SocketTimeoutException) { + RequestResult.NONE + } + if (result == RequestResult.WRONG_PIN) pinFailures++ + val quit = + when { + // a valid value was received, only one submission is accepted + result == RequestResult.VALUE_RECEIVED -> true + // too many wrong PINs, stop instead of allowing a brute force + pinFailures >= MAX_PIN_FAILURES -> true + // don't run forever if the screen stays open + System.currentTimeMillis() > deadline -> true + else -> false + } + if (quit) { + stop() + onStopped() + } + } + } catch (e: IOException) { + // the server socket was closed, the serving thread ends + Timber.d("Token server stopped: ${e.message}") + } + } + + private enum class RequestResult { + NONE, + WRONG_PIN, + VALUE_RECEIVED, + } + + /** Serve a single http request: the form page on GET /, the form processing on POST / */ + private fun handleClient(client: Socket): RequestResult { + return try { + client.soTimeout = CLIENT_TIMEOUT_MS + val reader = client.getInputStream().bufferedReader() + val requestLine = reader.readLine()?.take(MAX_REQUEST_LINE_LENGTH) ?: return RequestResult.NONE + var contentLength = 0 + while (true) { + val line = reader.readLine() ?: return RequestResult.NONE + if (line.isBlank()) break + if (line.startsWith("content-length:", ignoreCase = true)) { + contentLength = line.substringAfter(':').trim().toIntOrNull() ?: 0 + } + } + val parts = requestLine.split(' ') + val method = parts.getOrNull(0).orEmpty() + val path = parts.getOrNull(1).orEmpty().substringBefore('?') + + var result = RequestResult.NONE + val response: Response = + when { + path != "/" -> Response(404, messagePage(pages.errorMessage)) + method.equals("GET", ignoreCase = true) -> Response(200, formPage(includePin = !trusted)) + method.equals("POST", ignoreCase = true) -> { + val fields = readForm(reader, contentLength) + val value = fields["value"]?.trim() + when { + !trusted && !isPinValid(fields["pin"]?.trim()) -> { + result = RequestResult.WRONG_PIN + Response(401, messagePage(pages.wrongPinMessage)) + } + value.isNullOrBlank() || !isValueValid(value) -> + Response(200, messagePage(pages.errorMessage)) + else -> { + result = RequestResult.VALUE_RECEIVED + onValueReceived(value) + if (!trusted) onTrustEstablished() + Response(200, messagePage(pages.successMessage)) + } + } + } + else -> Response(405, messagePage(pages.errorMessage)) + } + + val body = response.page.toByteArray(Charsets.UTF_8) + val headers = + "HTTP/1.1 ${response.status} ${statusName(response.status)}\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "Content-Length: ${body.size}\r\n" + + "Cache-Control: no-store\r\n" + + "X-Content-Type-Options: nosniff\r\n" + + "X-Frame-Options: DENY\r\n" + + "Referrer-Policy: no-referrer\r\n" + + "Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; " + + "form-action 'self'\r\n" + + "Connection: close\r\n\r\n" + client.getOutputStream().apply { + write(headers.toByteArray(Charsets.UTF_8)) + write(body) + flush() + } + result + } catch (e: IOException) { + Timber.w(e, "Error handling a token server request") + RequestResult.NONE + } + } + + private data class Response(val status: Int, val page: String) + + private fun statusName(status: Int): String = + when (status) { + 200 -> "OK" + 401 -> "Unauthorized" + 404 -> "Not Found" + else -> "Method Not Allowed" + } + + /** Compare the submitted PIN in constant time */ + private fun isPinValid(submitted: String?): Boolean { + if (submitted == null) return false + return MessageDigest.isEqual(submitted.toByteArray(), pin.toByteArray()) + } + + /** Read an url encoded form body into its fields */ + private fun readForm(reader: BufferedReader, contentLength: Int): Map { + if (contentLength <= 0) return emptyMap() + val buffer = CharArray(contentLength.coerceAtMost(MAX_BODY_LENGTH)) + var read = 0 + while (read < buffer.size) { + val r = reader.read(buffer, read, buffer.size - read) + if (r == -1) break + read += r + } + return String(buffer, 0, read) + .split('&') + .mapNotNull { field -> + val separator = field.indexOf('=') + if (separator <= 0) null + else + try { + field.take(separator) to + URLDecoder.decode(field.substring(separator + 1), "UTF-8") + } catch (e: IllegalArgumentException) { + // malformed url encoding + null + } + } + .toMap() + } + + /** Find the local network (site local) ipv4 address of this device, if any */ + private fun findSiteLocalAddress(): InetAddress? = + try { + NetworkInterface.getNetworkInterfaces() + .asSequence() + .filter { it.isUp && !it.isLoopback } + .flatMap { it.inetAddresses.asSequence() } + .firstOrNull { it is Inet4Address && it.isSiteLocalAddress } + } catch (e: Exception) { + Timber.w(e, "Error looking for the local network address") + null + } + + /** Bind the first free port of [PORT_RANGE], only on [address], not on all the interfaces */ + private fun bindFirstFreePort(address: InetAddress): ServerSocket? { + for (port in PORT_RANGE) { + try { + return ServerSocket(port, BACKLOG, address) + } catch (e: IOException) { + // port already in use, try the next one + } + } + Timber.w("No free port found for the token server in $PORT_RANGE") + return null + } + + private fun formPage(includePin: Boolean): String { + val link = + if (pages.linkUrl != null && pages.linkLabel != null) + // CSP only restricts what the page loads or submits, not plain link navigation + """

""" + + """${pages.linkLabel.escapeHtml()}

""" + else "" + // a trusted phone (already passed the PIN this session) is not asked for it again + val pinField = + if (includePin) + """""" + + """""" + else "" + return """ + + + + ${pages.title.escapeHtml()} + +

${pages.title.escapeHtml()}

+ $link +
+ + + $pinField + +
+ """ + .trimIndent() + } + + private fun messagePage(message: String): String = + """ + + + + ${pages.title.escapeHtml()} + +

${pages.title.escapeHtml()}

+

${message.escapeHtml()}

+ """ + .trimIndent() + + private fun String.escapeHtml(): String = + replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) + + companion object { + /** A fresh random 6 digit PIN, generated with a cryptographically strong source */ + fun generatePin(): String = "%06d".format(SecureRandom().nextInt(1_000_000)) + + // predictable ports, easy to type manually if the qr code cannot be scanned + private val PORT_RANGE = 8080..8100 + private const val BACKLOG = 4 + private const val ACCEPT_TIMEOUT_MS = 15_000 + private const val CLIENT_TIMEOUT_MS = 5000 + private const val SERVER_LIFETIME_MS = 10 * 60 * 1000L + private const val MAX_PIN_FAILURES = 5 + private const val MAX_REQUEST_LINE_LENGTH = 2000 + private const val MAX_BODY_LENGTH = 10_000 + private const val PAGE_STYLE = + "body{font-family:sans-serif;margin:8vh auto;max-width:26em;padding:0 1em;" + + "background:#121212;color:#eee}" + + "input,button{font-size:1.1em;width:100%;box-sizing:border-box;margin-top:1em;" + + "padding:0.6em;border-radius:8px;border:1px solid #666;background:#1e1e1e;color:#eee}" + + "button{background:#7b5cd6;color:#fff;border:none}" + + "label{display:block;margin-top:1em}" + + "a{color:#a58cf0}" + } +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/view/AuthenticationFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/view/AuthenticationFragment.kt index 5a6886b7c..bec053bdd 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/view/AuthenticationFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/authentication/view/AuthenticationFragment.kt @@ -9,6 +9,7 @@ import android.text.style.UnderlineSpan import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ImageView import android.widget.TextView import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope @@ -26,7 +27,11 @@ import com.github.livingwithhippos.unchained.utilities.extension.copyToClipboard import com.github.livingwithhippos.unchained.utilities.extension.getClipboardText import com.github.livingwithhippos.unchained.utilities.extension.getThemeColor import com.github.livingwithhippos.unchained.utilities.extension.hideKeyboard +import com.github.livingwithhippos.unchained.utilities.extension.isTv +import com.github.livingwithhippos.unchained.utilities.extension.loadQrCode import com.github.livingwithhippos.unchained.utilities.extension.showToast +import com.github.livingwithhippos.unchained.utilities.tv.enablePhoneInput +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @@ -43,6 +48,10 @@ class AuthenticationFragment : UnchainedFragment() { private val binding get() = _binding!! + // content of the TV login QR code, shown in a dialog rather than inline so it does not take + // up screen space until the user asks for it + private var loginQrCodeContent: String? = null + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -94,6 +103,24 @@ class AuthenticationFragment : UnchainedFragment() { binding.bInsertPrivate.setOnClickListener { onSaveCodeClick(binding.tiPrivateCode) } + // typing a long token with a remote is painful: on TV the field offers a QR code icon + // that starts a temporary local server so the token can be sent from another device + binding.tfPrivateCode.enablePhoneInput( + scope = viewLifecycleOwner.lifecycleScope, + fieldLabel = getString(R.string.private_token), + linkUrl = API_TOKEN_URL, + linkLabel = getString(R.string.token_web_get_token_link), + errorMessage = getString(R.string.invalid_token), + // same minimum length checked by the manual token field + isValueValid = { it.length >= MIN_TOKEN_LENGTH }, + onValueReceived = { token -> + _binding?.let { + it.tiPrivateCode.setText(token, TextView.BufferType.EDITABLE) + onSaveCodeClick(it.tiPrivateCode) + } + }, + ) + activityViewModel.fsmAuthenticationState.observe(viewLifecycleOwner) { if (it != null) { when (it.peekContent()) { @@ -123,6 +150,8 @@ class AuthenticationFragment : UnchainedFragment() { binding.cbSecret.text = getString(R.string.waiting_user_auth) binding.tvUserCodeValue.text = getString(R.string.copy_code) binding.bCopyLink.isEnabled = false + binding.bShowLoginQrCode.visibility = View.GONE + loginQrCodeContent = null // get the authentication link to start the process viewModel.fetchAuthenticationInfo() @@ -164,6 +193,13 @@ class AuthenticationFragment : UnchainedFragment() { // let the user copy the user code to enter in the website binding.tvUserCodeValue.text = auth.userCode binding.bCopyLink.isEnabled = true + // TVs have no browser and typing the link with a remote is painful: offer a QR + // code, scannable with a phone, behind a button instead of showing it inline + if (requireContext().isTv()) { + loginQrCodeContent = auth.directVerificationUrl.ifBlank { auth.verificationUrl } + binding.bShowLoginQrCode.visibility = View.VISIBLE + binding.bShowLoginQrCode.setOnClickListener { showLoginQrCodeDialog() } + } // update the currently saved credentials activityViewModel.updateCredentialsDeviceCode(auth.deviceCode) // transition state machine @@ -254,6 +290,20 @@ class AuthenticationFragment : UnchainedFragment() { _binding = null } + /** Show the TV login QR code in a dialog instead of taking up space on the screen inline. */ + private fun showLoginQrCodeDialog() { + val content = loginQrCodeContent ?: return + val view = layoutInflater.inflate(R.layout.dialog_qr_code, null) + val qrView = view.findViewById(R.id.ivQrCode) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.login_qr_code_description) + .setView(view) + .setNegativeButton(R.string.close) { d, _ -> d.dismiss() } + .show() + // load the QR only once the dialog view is attached, otherwise loadQrCode skips it + qrView.loadQrCode(content, viewLifecycleOwner.lifecycleScope) + } + private fun getLoginMessage(type: Int): SpannableStringBuilder { val sb = SpannableStringBuilder() @@ -280,8 +330,7 @@ class AuthenticationFragment : UnchainedFragment() { fun onSaveCodeClick(codeInputField: TextInputEditText) { val token: String = codeInputField.text.toString().trim() - // mine is 52 characters - if (token.length < 40) context?.showToast(R.string.invalid_token) + if (token.length < MIN_TOKEN_LENGTH) context?.showToast(R.string.invalid_token) else { // pass the value to be checked and eventually saved activityViewModel.updateCredentials( @@ -298,5 +347,10 @@ class AuthenticationFragment : UnchainedFragment() { companion object { const val LOGIN_TYPE_DIRECT = 0 const val LOGIN_TYPE_INDIRECT = 1 + + // same minimum used for the manual private token input, mine is 52 characters + private const val MIN_TOKEN_LENGTH = 40 + // the page where the private token can be copied from + private const val API_TOKEN_URL = "https://real-debrid.com/apitoken" } } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/view/NewDownloadFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/view/NewDownloadFragment.kt index 57e815e7c..e3b3015fc 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/view/NewDownloadFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/newdownload/view/NewDownloadFragment.kt @@ -43,6 +43,7 @@ import com.github.livingwithhippos.unchained.utilities.extension.isMagnet import com.github.livingwithhippos.unchained.utilities.extension.isSimpleWebUrl import com.github.livingwithhippos.unchained.utilities.extension.isTorrent import com.github.livingwithhippos.unchained.utilities.extension.isWebUrl +import com.github.livingwithhippos.unchained.utilities.tv.enablePhoneInput import dagger.hilt.android.AndroidEntryPoint import java.io.IOException import kotlinx.coroutines.delay @@ -382,6 +383,27 @@ class NewDownloadFragment : UnchainedFragment() { } else viewModel.postMessage(getString(R.string.premium_needed)) } + // typing a magnet or a link with a remote is the worst TV interaction in the app: on TV + // the field offers a QR code icon that starts a temporary local server so the link can be + // sent from another device, e.g. the user's phone. It only fills the field, exactly as if + // it had been typed: the user still presses the download or upload button themselves + binding.tfLink.enablePhoneInput( + scope = viewLifecycleOwner.lifecycleScope, + fieldLabel = getString(R.string.link_or_magnet), + errorMessage = getString(R.string.invalid_url), + // same checks used by the paste button + isValueValid = { value -> + value.isWebUrl() || + value.isSimpleWebUrl() || + value.isMagnet() || + value.isTorrent() || + value.split("\n").firstOrNull()?.trim()?.isWebUrl() == true + }, + onValueReceived = { link -> + _binding?.tiLink?.setText(link, TextView.BufferType.EDITABLE) + }, + ) + binding.bPasteLink.setOnClickListener { val pasteText = getClipboardText().trim() diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteDeviceFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteDeviceFragment.kt index e2cb0dc09..17de341b0 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteDeviceFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteDeviceFragment.kt @@ -9,6 +9,7 @@ import android.widget.PopupMenu import androidx.annotation.MenuRes import androidx.appcompat.app.AlertDialog import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.selection.SelectionPredicates @@ -22,6 +23,7 @@ import com.github.livingwithhippos.unchained.databinding.FragmentRemoteDeviceBin import com.github.livingwithhippos.unchained.remotedevice.viewmodel.DeviceEvent import com.github.livingwithhippos.unchained.remotedevice.viewmodel.DeviceViewModel import com.github.livingwithhippos.unchained.utilities.extension.showToast +import com.github.livingwithhippos.unchained.utilities.tv.enablePhoneInput import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -93,6 +95,11 @@ class RemoteDeviceFragment : UnchainedFragment(), ServiceListListener { viewModel.fetchDeviceServices(item.id) } + // typing the name and address with a remote is painful: on TV both fields offer a QR code + // icon that starts a temporary local server so their value can be sent from another device + binding.tfName.enablePhoneInput(viewLifecycleOwner.lifecycleScope) + binding.tfAddress.enablePhoneInput(viewLifecycleOwner.lifecycleScope) + binding.fabDeviceAction.setOnClickListener { showMenu(it, R.menu.device_page_action) } binding.bDeleteDevice.setOnClickListener { diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteServiceFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteServiceFragment.kt index 440e792ad..ad214159d 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteServiceFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteServiceFragment.kt @@ -8,6 +8,7 @@ import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.github.livingwithhippos.unchained.R @@ -18,6 +19,7 @@ import com.github.livingwithhippos.unchained.databinding.FragmentRemoteServiceBi import com.github.livingwithhippos.unchained.remotedevice.viewmodel.DeviceEvent import com.github.livingwithhippos.unchained.remotedevice.viewmodel.DeviceViewModel import com.github.livingwithhippos.unchained.utilities.extension.showToast +import com.github.livingwithhippos.unchained.utilities.tv.enablePhoneInput import dagger.hilt.android.AndroidEntryPoint import timber.log.Timber @@ -83,6 +85,11 @@ class RemoteServiceFragment : Fragment() { } } + // typing the name and the API token with a remote is painful: on TV both fields offer a QR + // code icon that starts a temporary local server so their value can be sent from a phone + binding.tfName.enablePhoneInput(viewLifecycleOwner.lifecycleScope) + binding.tfApiToken.enablePhoneInput(viewLifecycleOwner.lifecycleScope) + binding.bTestService.setOnClickListener { val username = binding.tiUsername.text.toString().trim() val password = binding.tiPassword.text.toString().trim() diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceFragment.kt index 0e7000634..e47a99680 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceFragment.kt @@ -8,6 +8,7 @@ import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.github.livingwithhippos.unchained.R @@ -18,6 +19,7 @@ import com.github.livingwithhippos.unchained.databinding.FragmentCompleteService import com.github.livingwithhippos.unchained.remoteservice.viewmodel.ServiceEvent import com.github.livingwithhippos.unchained.remoteservice.viewmodel.ServiceViewModel import com.github.livingwithhippos.unchained.utilities.extension.showToast +import com.github.livingwithhippos.unchained.utilities.tv.enablePhoneInput import dagger.hilt.android.AndroidEntryPoint import timber.log.Timber @@ -82,6 +84,13 @@ class CompleteServiceFragment : Fragment() { } } + // typing the name, address and API token with a remote is painful: on TV these fields + // offer a QR code icon that starts a temporary local server so their value can be sent + // from another device, e.g. the user's phone + binding.tfName.enablePhoneInput(viewLifecycleOwner.lifecycleScope) + binding.tfAddress.enablePhoneInput(viewLifecycleOwner.lifecycleScope) + binding.tfApiToken.enablePhoneInput(viewLifecycleOwner.lifecycleScope) + binding.bTestService.setOnClickListener { val username = binding.tiUsername.text.toString().trim() val password = binding.tiPassword.text.toString().trim() diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/AddRepositoryDialogFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/AddRepositoryDialogFragment.kt index c2d31f0ab..0e1b7d6c1 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/AddRepositoryDialogFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/repository/view/AddRepositoryDialogFragment.kt @@ -8,15 +8,18 @@ import android.view.ViewGroup import android.widget.Button import androidx.fragment.app.DialogFragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope import com.github.livingwithhippos.unchained.R import com.github.livingwithhippos.unchained.repository.viewmodel.InvalidLinkReason import com.github.livingwithhippos.unchained.repository.viewmodel.PluginRepositoryEvent import com.github.livingwithhippos.unchained.repository.viewmodel.RepositoryViewModel import com.github.livingwithhippos.unchained.utilities.extension.isWebUrl import com.github.livingwithhippos.unchained.utilities.extension.showToast +import com.github.livingwithhippos.unchained.utilities.tv.enablePhoneInput import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.progressindicator.LinearProgressIndicator import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -39,6 +42,17 @@ class AddRepositoryDialogFragment : DialogFragment() { progressBar = view.findViewById(R.id.progressBar) + // typing a repository URL with a remote is painful: on TV offer a QR code icon that + // starts a temporary local server so the URL can be sent from another device + view + .findViewById(R.id.tfAdd) + .enablePhoneInput( + scope = lifecycleScope, + fieldLabel = getString(R.string.link), + errorMessage = getString(R.string.invalid_url), + isValueValid = { it.isWebUrl() }, + ) + view.findViewById