Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions android/plugchoice/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ dependencies {
api("androidx.activity:activity-ktx:1.12.4")
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.webkit:webkit:1.15.0")
// auth.webSession: Google and Apple sign-in in Chrome's Auth Tab.
implementation("androidx.browser:browser:1.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
// camera.scanCode: the Google code scanner (Play services UI, no camera permission).
Expand Down
7 changes: 7 additions & 0 deletions android/plugchoice/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@
android:name="android.hardware.bluetooth_le"
android:required="false" />

<!-- auth.webSession: find a browser that supports Custom Tabs, to check for Auth Tab (API 30+). -->
<queries>
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>

<application>
<!--
configChanges: the Link UI must never reload mid-flow (it would lose its
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.plugchoice.internal

import android.content.Context
import android.net.Uri
import androidx.browser.auth.AuthTabIntent
import com.plugchoice.LinkResult

/** What the bridge needs from the activity hosting it. All calls happen on the main thread. */
Expand All @@ -11,6 +13,9 @@ internal interface BridgeHost {
/** Runtime permission request through the activity; returns permission → granted. */
suspend fun requestPermissions(permissions: Array<String>): Map<String, Boolean>

/** `auth.webSession`: opens [url] in an Auth Tab that closes on [scheme], and waits for how it ended. */
suspend fun openAuthTab(url: Uri, scheme: String): AuthTabIntent.AuthResult

/** `session.close`, or the shell closing on its own: dismiss the Link screen and hand [result] to the host app. */
fun closeSession(result: LinkResult)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.browser.auth.AuthTabIntent
import androidx.activity.SystemBarStyle
import androidx.activity.addCallback
import androidx.activity.enableEdgeToEdge
Expand Down Expand Up @@ -90,6 +91,15 @@ internal class LinkActivity : ComponentActivity() {
pendingPermissions = null
}

private var pendingAuthTab: CompletableDeferred<AuthTabIntent.AuthResult>? = null

// auth.webSession: the Auth Tab answers this activity directly (no intent filter).
private val authTabLauncher =
AuthTabIntent.registerActivityResultLauncher(this) { result ->
pendingAuthTab?.complete(result)
pendingAuthTab = null
}

private val host = object : BridgeHost {
override val hostContext: Context
get() = this@LinkActivity
Expand All @@ -102,6 +112,13 @@ internal class LinkActivity : ComponentActivity() {
deferred.await()
}

override suspend fun openAuthTab(url: Uri, scheme: String): AuthTabIntent.AuthResult {
val deferred = CompletableDeferred<AuthTabIntent.AuthResult>()
pendingAuthTab = deferred
AuthTabIntent.Builder().build().launch(authTabLauncher, url, scheme)
return deferred.await()
}

override fun closeSession(result: LinkResult) {
finishWith(result)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ internal class LinkBridge(
private val wifi: WifiController = WifiController(host, onNetworkChanged = { net.onNetworkChanged() })
private val net: LocalNetworkClient = LocalNetworkClient(wifi, emit = this::emitEvent)
private val scanner = CodeScanner(host)
private val webSignIn = WebSignIn(host)
private val discovery = LanDiscovery(host.hostContext)
private val sessions = HttpSessions(TlsSessionConnector)
private val tcp = TcpSockets(emit = this::emitEvent)
Expand All @@ -60,6 +61,7 @@ internal class LinkBridge(
)
private val clientSecrets = ClientSecrets(scope, fetchClientSecret)
private val bluetoothAvailable: Boolean by lazy { Transports.bluetoothAvailable(host.hostContext) }
private val webSessionAvailable: Boolean by lazy { WebSignIn.isAvailable(host.hostContext) }
private var disposed = false

/** Bumped for each new document: answers to the previous document's requests are dropped. */
Expand Down Expand Up @@ -157,6 +159,7 @@ internal class LinkBridge(
JSONObject()
}
"camera.scanCode" -> scanner.scan(params)
"auth.webSession" -> webSignIn.signIn(params)
"lan.discover" -> discover(LanDiscovery.parseRequest(params))
"lan.stopDiscovery" -> {
discovery.stop()
Expand Down Expand Up @@ -280,7 +283,7 @@ internal class LinkBridge(
.put("sdkVersion", Plugchoice.SDK_VERSION)
.put("platform", "android")
.put("osVersion", Build.VERSION.RELEASE)
.put("capabilities", JSONArray(capabilities(bluetoothAvailable)))
.put("capabilities", JSONArray(capabilities(bluetoothAvailable, webSessionAvailable)))
.put("lanServiceTypes", JSONObject.NULL)
}

Expand Down Expand Up @@ -374,11 +377,13 @@ internal class LinkBridge(
/**
* `hello`'s capabilities (§5). No `wifi.accessory` on Android (that's AccessorySetupKit on
* iOS); `ble` when [bluetoothAvailable] (the device has Bluetooth LE and the host kept the
* library's Bluetooth permissions).
* library's Bluetooth permissions); `auth.webSession` when [webSessionAvailable] (the
* browser supports Auth Tab).
*/
fun capabilities(bluetoothAvailable: Boolean): List<String> = buildList {
fun capabilities(bluetoothAvailable: Boolean, webSessionAvailable: Boolean = false): List<String> = buildList {
addAll(listOf("wifi.join", "http.request", "ws", "session.close", "camera.scanCode", "ui.closeRequest"))
addAll(listOf("lan.address", "lan.discover", "http.session", "auth.clientSecret"))
if (webSessionAvailable) add("auth.webSession")
if (bluetoothAvailable) add("ble")
addAll(listOf("tcp", "udp", "trust.custom"))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.plugchoice.internal

import android.content.Context
import android.net.Uri
import androidx.browser.auth.AuthTabIntent
import androidx.browser.customtabs.CustomTabsClient
import org.json.JSONObject
import java.net.URI
import java.net.URISyntaxException

/**
* `auth.webSession` (PROTOCOL.md §7.1): Google or Apple sign-in in Chrome's Auth Tab, since Google
* refuses an embedded web view. The tab closes on the `plugchoice-link` scheme and hands that URL
* straight back to the activity that opened it, so no intent filter is involved and apps with the
* SDK never compete for the scheme. Browsers without Auth Tab don't get the capability. The URL
* holds a one-time code: never logged.
*/
internal class WebSignIn(private val host: BridgeHost) {

private var showing = false

suspend fun signIn(params: JSONObject): JSONObject {
val value = params.optStringOrNull("url")
if (!isWebSessionUrl(value)) throw BridgeException.invalidParams("url must be an https URL")
val url = Uri.parse(value)
if (showing) throw BridgeException(ErrorCode.UNAVAILABLE, "a sign-in is already showing")
if (!isAvailable(host.hostContext)) throw BridgeException(ErrorCode.UNAVAILABLE, "the browser has no Auth Tab")

showing = true
try {
val result = host.openAuthTab(url, CALLBACK_SCHEME)
return when (result.resultCode) {
AuthTabIntent.RESULT_OK -> {
val callback = result.resultUri ?: throw BridgeException(ErrorCode.UNAVAILABLE, "the sign-in returned no URL")
JSONObject().put("callbackUrl", callback.toString())
}
AuthTabIntent.RESULT_CANCELED -> throw BridgeException(ErrorCode.USER_CANCELLED, "sign-in closed")
else -> throw BridgeException(ErrorCode.UNAVAILABLE, "sign-in failed (${result.resultCode})")
}
} finally {
showing = false
}
}

companion object {
const val CALLBACK_SCHEME = "plugchoice-link"

/** Whether the default browser (or another Custom Tabs browser) supports Auth Tab. */
fun isAvailable(context: Context): Boolean {
val browser = CustomTabsClient.getPackageName(context, null) ?: return false
return CustomTabsClient.isAuthTabSupported(context, browser)
}

/** `url`: an `https` URL with a host (core's sign-in hop). Plain JVM, so tests can run it. */
fun isWebSessionUrl(value: String?): Boolean {
if (value.isNullOrEmpty()) return false
val uri = try {
URI(value)
} catch (_: URISyntaxException) {
return false
}
return uri.scheme.equals("https", ignoreCase = true) && !uri.host.isNullOrEmpty()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ class BridgeTest {
assertEquals(LinkBridge.capabilities(bluetoothAvailable = true) - "ble", LinkBridge.capabilities(bluetoothAvailable = false))
}

@Test
fun `auth webSession only where the browser has Auth Tab`() {
val with = LinkBridge.capabilities(bluetoothAvailable = false, webSessionAvailable = true)
assertTrue("auth.webSession" in with)
assertEquals(with - "auth.webSession", LinkBridge.capabilities(bluetoothAvailable = false))
}

@Test
fun `a web session opens https URLs only`() {
assertTrue(WebSignIn.isWebSessionUrl("https://app.plugchoice.com/auth/google/link-run?ticket=t"))
assertFalse(WebSignIn.isWebSessionUrl(null))
assertFalse(WebSignIn.isWebSessionUrl(""))
assertFalse(WebSignIn.isWebSessionUrl("http://app.plugchoice.com/auth"))
assertFalse(WebSignIn.isWebSessionUrl("plugchoice-link://auth"))
assertFalse(WebSignIn.isWebSessionUrl("https://"))
assertFalse(WebSignIn.isWebSessionUrl("https://exa mple.com"))
}

// session.close

@Test
Expand Down
20 changes: 17 additions & 3 deletions bridge/PROTOCOL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Plugchoice bridge protocol, version 1

The contract between the hosted page at `https://connect.plugchoice.com` and the native shells in this repository (`ios/`, `android/`). The page runs the flow (setting up a charger, today); the shell only does what a web page can't: join a device's Wi-Fi, find devices on the local network and talk HTTP, WebSocket, TCP and UDP to them, talk Bluetooth, scan a code with the camera, fetch the client secret from the host app, and close the screen with a result.
The contract between the hosted page at `https://connect.plugchoice.com` and the native shells in this repository (`ios/`, `android/`). The page runs the flow (setting up a charger, today); the shell only does what a web page can't: join a device's Wi-Fi, find devices on the local network and talk HTTP, WebSocket, TCP and UDP to them, talk Bluetooth, scan a code with the camera, fetch the client secret from the host app, open the system sign-in sheet for Google and Apple, and close the screen with a result.

Design rules:

Expand Down Expand Up @@ -111,6 +111,7 @@ The page sends `hello` first and waits for the answer before anything else.
| `lan.discover` | `lan.discover`, `lan.stopDiscovery` (§9.4) | iOS: the host declares at least one `NSBonjourServices` type. Android: always |
| `http.session` | `http.session.open`, `.request`, `.close` (§9.5) | Always |
| `auth.clientSecret` | `auth.clientSecret` (§7) | Always |
| `auth.webSession` | `auth.webSession` (§7.1) | iOS: always. Android: the default browser supports Auth Tab |
| `ble` | `ble.*` (§11) | The device has Bluetooth LE, and iOS: the host has `NSBluetoothAlwaysUsageDescription`; Android: the host kept the library's Bluetooth permissions |
| `tcp` | `tcp.open`, `tcp.write`, `tcp.close` (§9.6) | Always |
| `udp` | `udp.exchange` (§9.7) | Always |
Expand Down Expand Up @@ -165,6 +166,19 @@ When the shell closes by itself, it reports the action it opened with, no `sessi
- `clientSecretUnavailable`: the callback threw, returned an empty (or blank) string, or took longer than 30 s. The page shows its error screen with **Try again**, which calls `auth.clientSecret` again.
- The shell never logs the secret and keeps no copy after answering.

### 7.1 `auth.webSession`

| Params | Result | Errors |
|---|---|---|
| `{ url: string }` | `{ callbackUrl: string }` | `userCancelled`, `unavailable` |

- Signing in with Google or Apple. Google refuses to sign in inside an embedded web view, so the shell opens `url` (an `https` URL from core's sign-in hop) in the system's sign-in sheet, outside the page. The sheet closes when it reaches a URL with the scheme `plugchoice-link`, and `callbackUrl` is that URL, as the page reads it (core puts the result in its fragment).
- The host app needs nothing: the sheet catches the scheme itself, and no app has to register it. Whatever comes back is worth nothing without the run's credential, which only the page holds.
- `userCancelled`: the user closed the sheet. `unavailable`: a sheet is already showing, or the system couldn't show one. A `url` that isn't `https` is `invalidParams`.
- iOS: `ASWebAuthenticationSession` with callback scheme `plugchoice-link`, sharing Safari's cookies (not ephemeral: someone signed in to Google in Safari picks their account in one tap). iOS asks first whether the app may use the website to sign in.
- Android: Chrome's Auth Tab (androidx.browser `AuthTabIntent`) with the same scheme. Listed only when the default browser supports Auth Tab; elsewhere the page keeps sign-in by code. No intent filter is involved, so apps with the SDK never compete for the scheme.
- `callbackUrl` holds a one-time code: shells never log it.

## 8. Wi-Fi

### `wifi.ensurePermissions`
Expand Down Expand Up @@ -417,7 +431,7 @@ Not AccessorySetupKit: declaring it for Bluetooth limits CoreBluetooth in the wh
| `udp.exchange` | `timeoutMs` clamped to 100 ms to 30 s; `maxReplies` 1 to 1000; a datagram up to 65 507 bytes |
| `ble.scan`, `ble.connect` | `timeoutMs` clamped to 1 s to 60 s |
| Bluetooth request | 10 s; `ble.write` up to 512 bytes; `mtu` 23 to 517 |
| At once | 1 `lan.discover`, 1 `ble.scan`, 1 `camera.scanCode`; 64 sessions, 16 TCP sockets, 8 Bluetooth connections |
| At once | 1 `lan.discover`, 1 `ble.scan`, 1 `camera.scanCode`, 1 `auth.webSession`; 64 sessions, 16 TCP sockets, 8 Bluetooth connections |

## 13. Error codes

Expand All @@ -433,7 +447,7 @@ Not AccessorySetupKit: declaring it for Bluetooth limits CoreBluetooth in the wh
| `undeclaredServiceType`, `busy` | `lan.discover` (`undeclaredServiceType` iOS); `busy` also `ble.scan`, `ble.connect` |
| `unknownSession`, `tooManySessions` | `http.session.*` |
| `unknownSocket`, `tooManySockets` | `tcp.*` |
| `userCancelled`, `cameraPermissionDenied`, `unavailable` | `camera.scanCode`; `unavailable` also `ble.*` |
| `userCancelled`, `cameraPermissionDenied`, `unavailable` | `camera.scanCode`; `userCancelled` and `unavailable` also `auth.webSession`; `unavailable` also `ble.*` |
| `bluetoothOff`, `bluetoothPermissionDenied`, `unknownDevice`, `notConnected`, `unknownCharacteristic`, `notPermitted`, `tooManyConnections`, `gatt` | `ble.*` |

`LinkResult.error` codes the shell sets itself: `pageLoadFailed` and `internal` (§6.3). Every other code comes from the page.
Expand Down
56 changes: 56 additions & 0 deletions ios/Sources/PlugchoiceSDK/Auth/WebSignIn.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import AuthenticationServices
import UIKit

/// `auth.webSession` (PROTOCOL.md §7.1): Google or Apple sign-in in the
/// system's sign-in sheet, since Google refuses an embedded web view. The
/// sheet closes on the `plugchoice-link` scheme; that URL goes back to the
/// page. It holds a one-time code: never logged.
@MainActor
final class WebSignIn: NSObject, ASWebAuthenticationPresentationContextProviding {
static let callbackScheme = "plugchoice-link"

private var showing: ASWebAuthenticationSession?
private weak var anchor: UIWindow?

func signIn(url: URL, from presenter: UIViewController?) async throws -> JSONObject {
guard showing == nil else {
throw BridgeError(code: "unavailable", message: "a sign-in is already showing")
}
guard let window = presenter?.viewIfLoaded?.window else {
throw BridgeError(code: "unavailable", message: "nothing to show the sign-in over")
}
anchor = window
defer { showing = nil }

let callback = try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: Self.callbackScheme) { callbackURL, error in
if let callbackURL {
continuation.resume(returning: callbackURL)
} else if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin {
continuation.resume(throwing: BridgeError(code: "userCancelled", message: "sign-in closed"))
} else {
continuation.resume(throwing: BridgeError(code: "unavailable", message: "sign-in failed: \(error.map { String(describing: type(of: $0)) } ?? "unknown")"))
}
}
session.presentationContextProvider = self
// Shares Safari's cookies: someone signed in to Google there picks their account in one tap.
session.prefersEphemeralWebBrowserSession = false
showing = session
if !session.start() {
showing = nil
continuation.resume(throwing: BridgeError(code: "unavailable", message: "the sign-in sheet didn't start"))
}
}
} onCancel: {
// The page went away or the screen is closing: the session's
// completion answers with canceledLogin.
Task { @MainActor in self.showing?.cancel() }
}
return ["callbackUrl": callback.absoluteString]
}

nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
MainActor.assumeIsolated { anchor ?? ASPresentationAnchor() }
}
}
Loading
Loading