feat(client/android): copy a host's punktfunk:// link #79

Merged
enricobuehler merged 1 commits from worktree-android-copy-link into main 2026-08-06 19:32:35 +00:00
3 changed files with 47 additions and 7 deletions
@@ -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)
@@ -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))
+5 -4
View File
@@ -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