From f33e442b4340c6c338ddb2a4381be9ba49759f0d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 21:20:36 +0200 Subject: [PATCH] feat(client/android): copy a host's punktfunk:// link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android was the one app that could open a punktfunk:// link but never hand one out, so every Android link had to be typed by hand — and the host's stable record id, which is the part that keeps a link working after the box changes address, isn't shown anywhere in the UI to type. Both homes now offer Copy link: the touch grid's card overflow menu, and the controller home's host options (Up on a tile). A pinned card copies its own profile with it, matching Linux and Apple; a host card copies none and so keeps honouring the host's binding, exactly like tapping it does. The URL is the shared self-emitted form (DeepLinks.forHost), already covered by the cross-language vector tests, so the three emitters stay in step. Android 13+ draws its own clipboard confirmation and we add nothing on top of it; below that we say so ourselves, as a toast in the console home, which renders neither banner. --- .../kotlin/io/unom/punktfunk/ConnectScreen.kt | 31 +++++++++++++++++++ .../io/unom/punktfunk/GamepadDialogs.kt | 14 +++++++-- docs-site/content/docs/profiles-and-links.md | 9 +++--- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index a0264bac..69dae7d1 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -1,11 +1,14 @@ package io.unom.punktfunk import android.Manifest +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement @@ -622,6 +625,32 @@ fun ConnectScreen( savedHosts = knownHostStore.all() } + // "Copy link" — the self-emitted form every other client already hands out + // (design/client-deep-links.md §4): the host's STABLE id first, with `host=` and `fp=` alongside, + // so a link written today still lands on the right box after the host changes address or this + // client is reinstalled. A PINNED card copies its own profile with it, because that combination + // is the thing being copied; a host card copies no profile at all and so keeps honouring the + // host's binding, exactly like a tap on it does. + fun copyLink(kh: KnownHost, pin: StreamProfile?) { + val url = DeepLinks.forHost(kh, profile = pin?.id).toUrl() + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + val copied = clipboard != null && runCatching { + clipboard.setPrimaryClip(ClipData.newPlainText("Punktfunk link", url)) + }.isSuccess + // Android 13 draws its own clipboard confirmation, and stacking a second one on top of it is + // the platform's own documented anti-pattern. Below it nothing visible happens at all unless + // we say so — a silent menu item reads as a broken one. + if (copied && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return + val message = if (copied) "Link copied." else "Couldn't copy the link to the clipboard." + // The console home renders neither the notice nor the status banner, so there it has to be a + // toast; the touch grid has both, and a success dressed as an error banner is a small lie. + when { + gamepadUi -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + copied -> notice = message + else -> status = message + } + } + // The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a // user who never wants this feature sees no new clutter anywhere but the settings scope chips. // "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding @@ -630,6 +659,7 @@ fun ConnectScreen( if (pin == null) { add(HostMenuItem("Network speed test") { startSpeedTest(HostCardEntry(kh, null)) }) } + add(HostMenuItem("Copy link") { copyLink(kh, pin) }) if (profiles.isEmpty()) return@buildList if (pin != null) { add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) }) @@ -1162,6 +1192,7 @@ fun ConnectScreen( } else { null }, + onCopyLink = { optionsTarget = null; copyLink(kh, pin) }, onEdit = { optionsTarget = null; editTarget = kh }, onForget = { knownHostStore.remove(kh) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt index c9affdb8..ecfdf1c6 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt @@ -205,9 +205,9 @@ private fun DialogText(text: String) { } /** - * Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Edit, - * Forget. Reached by pressing Up on a focused saved host in the carousel; the console counterpart of - * the touch host card's overflow menu. + * Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Copy + * link, Edit, Forget. Reached by pressing Up on a focused saved host in the carousel; the console + * counterpart of the touch host card's overflow menu. */ @Composable fun GamepadHostOptionsDialog( @@ -217,6 +217,12 @@ fun GamepadHostOptionsDialog( onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y onEdit: () -> Unit, onForget: () -> Unit, + /** + * Copy this tile's `punktfunk://` link. Offered on a pinned tile too — unlike the host's other + * actions it says nothing about the host, it hands out the shortcut this very tile already is + * (profile included), which is exactly what a pin is for. + */ + onCopyLink: () -> Unit, onDismiss: () -> Unit, onSpeedTest: (() -> Unit)? = null, /** @@ -233,12 +239,14 @@ fun GamepadHostOptionsDialog( actions = buildList { if (onUnpin != null) { add(DialogAction("Unpin card", primary = true, onClick = onUnpin)) + add(DialogAction("Copy link", onClick = onCopyLink)) add(DialogAction("Cancel", onClick = onDismiss)) return@buildList } if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary)) if (canWake) add(DialogAction("Wake host", onClick = onWake)) if (onSpeedTest != null) add(DialogAction("Network speed test", onClick = onSpeedTest)) + add(DialogAction("Copy link", onClick = onCopyLink)) add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit)) add(DialogAction("Forget", onClick = onForget)) add(DialogAction("Cancel", onClick = onDismiss)) diff --git a/docs-site/content/docs/profiles-and-links.md b/docs-site/content/docs/profiles-and-links.md index af8c8f07..8beac305 100644 --- a/docs-site/content/docs/profiles-and-links.md +++ b/docs-site/content/docs/profiles-and-links.md @@ -164,11 +164,12 @@ could do, minus every trust decision.** ## Getting a link, and making a shortcut On Linux and Windows a host card's menu has **Copy link** and **Create shortcut…**. On macOS and iOS -the card menu has **Copy Link** (tvOS has no clipboard, so it isn't offered; the Android app has no -copy action yet). +the card menu has **Copy Link**; tvOS has no clipboard, so it isn't offered there. Android has +**Copy link** in both of its homes — the touch grid's card menu, and the controller home's host +options (press Up on a host's tile). -On Linux and Apple a pinned card has its own menu, and the link it hands out carries that card's -profile. Windows pinned tiles have no menu, and neither Windows action adds a `profile=`, so a +On Linux, Apple and Android a pinned card has its own menu, and the link it hands out carries that +card's profile. Windows pinned tiles have no menu, and neither Windows action adds a `profile=`, so a Windows link always uses the host's binding until you edit the URL yourself. A copied link carries the host's stable record id, plus `host=` and `fp=` (the fingerprint only when -- 2.54.0