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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Let the people using your app connect their EV chargers to Plugchoice, without l

## Features

- **Charger setup**: add a charger, set it up, connect it to Wi-Fi, or reconnect it, guided screen by screen.
- **Charger setup**: add a charger, set it up, or reconnect it (which also changes its network, such as its Wi-Fi), guided screen by screen.
- **Many brands, no app update**: new charger brands and fixes reach your users without a new release of your app.
- **One backend endpoint**: your server creates a session, scoped to the chargers your user may touch; the SDK fetches it when it needs one.
- **Native where it counts**: Wi-Fi hotspots, local network discovery, Bluetooth LE and QR scanning, behind each platform's own permission prompts.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ class MainActivity : ComponentActivity() {

// Testing aid, like the iOS example's `-autoOpen`:
// `adb shell am start -n com.plugchoice.example/.MainActivity --es clientSecret cs_… \
// --es host 10.0.2.2:5173 --es action network --es chargerId 42 --ez autoOpen true`
// --es host 10.0.2.2:5173 --es action reconnect --es chargerId 42 --ez autoOpen true`
if (savedInstanceState == null) {
intent.getStringExtra(EXTRA_CLIENT_SECRET)?.let(secretField::setText)
intent.getStringExtra(EXTRA_HOST)?.let(hostField::setText)
Expand Down Expand Up @@ -174,7 +174,6 @@ class MainActivity : ComponentActivity() {
val siteId = siteIdField.text.toString().trim().ifEmpty { null }
return when (val name = ACTIONS[actionPicker.selectedItemPosition]) {
LinkAction.ADD -> LinkAction.addCharger(siteId)
LinkAction.NETWORK -> LinkAction.network(chargerId)
LinkAction.SETUP -> LinkAction.setup(chargerId)
LinkAction.RECONNECT -> LinkAction.reconnect(chargerId)
else -> customActionField.text.toString().trim().takeIf { it.isNotEmpty() && name == CUSTOM }
Expand Down Expand Up @@ -217,6 +216,6 @@ class MainActivity : ComponentActivity() {
const val EXTRA_AUTO_OPEN = "autoOpen"

const val CUSTOM = "custom…"
val ACTIONS = listOf(LinkAction.ADD, LinkAction.NETWORK, LinkAction.SETUP, LinkAction.RECONNECT, CUSTOM)
val ACTIONS = listOf(LinkAction.ADD, LinkAction.SETUP, LinkAction.RECONNECT, CUSTOM)
}
}
2 changes: 1 addition & 1 deletion android/example/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<string name="action_label">Action</string>
<string name="custom_action_hint">Action name</string>
<string name="custom_action_missing">Enter an action name</string>
<string name="charger_id_label">Charger id (network, setup, reconnect)</string>
<string name="charger_id_label">Charger id (setup, reconnect)</string>
<string name="charger_id_hint">42</string>
<string name="site_id_label">Site id (add, optional)</string>
<string name="site_id_hint">Optional</string>
Expand Down
15 changes: 6 additions & 9 deletions android/plugchoice/src/main/java/com/plugchoice/LinkAction.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,15 @@ package com.plugchoice
* ```kotlin
* LinkAction.addCharger() // add a charger
* LinkAction.addCharger(siteId) // add a charger at a site
* LinkAction.network(chargerId) // change a charger's network (Wi-Fi)
* LinkAction.setup(chargerId) // set a charger up
* LinkAction.reconnect(chargerId) // reconnect a charger to Plugchoice
* LinkAction.reconnect(chargerId) // get a charger reaching Plugchoice again, or change its network
* LinkAction.custom("later-action", chargerId)
* ```
*
* The SDK passes the action to the page without reading it, so an action the page learns later
* works through [custom] before it gets a helper here. Empty ids are left out.
*
* @property name The action, for example `"add"` or `"network"`.
* @property name The action, for example `"add"` or `"reconnect"`.
*/
public class LinkAction private constructor(
public val name: String,
Expand All @@ -31,7 +30,6 @@ public class LinkAction private constructor(

public companion object {
public const val ADD: String = "add"
public const val NETWORK: String = "network"
public const val SETUP: String = "setup"
public const val RECONNECT: String = "reconnect"

Expand All @@ -40,15 +38,14 @@ public class LinkAction private constructor(
@JvmOverloads
public fun addCharger(siteId: String? = null): LinkAction = of(ADD, chargerId = null, siteId = siteId)

/** Change the network (Wi-Fi) of the charger [chargerId]. */
@JvmStatic
public fun network(chargerId: String): LinkAction = of(NETWORK, chargerId, siteId = null)

/** Set up the charger [chargerId]. */
@JvmStatic
public fun setup(chargerId: String): LinkAction = of(SETUP, chargerId, siteId = null)

/** Reconnect the charger [chargerId] to Plugchoice. */
/**
* Get the charger [chargerId], already on Plugchoice, reaching Plugchoice again. This also
* changes its network (Wi-Fi and the like), so open it from a "Network settings" button too.
*/
@JvmStatic
public fun reconnect(chargerId: String): LinkAction = of(RECONNECT, chargerId, siteId = null)

Expand Down
4 changes: 2 additions & 2 deletions android/plugchoice/src/main/java/com/plugchoice/Plugchoice.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import com.plugchoice.internal.Transports
* val plugchoice = Plugchoice(fetchClientSecret = { action -> backend.plugchoiceClientSecret(action) })
*
* private val openLink = registerForActivityResult(plugchoice.link.contract()) { result: LinkResult -> … }
* openLink.launch(LinkAction.network(chargerId))
* openLink.launch(LinkAction.reconnect(chargerId))
* ```
*
* @param fetchClientSecret Returns a client secret your server got from
Expand All @@ -34,7 +34,7 @@ public class Plugchoice(
) {
internal val fetchClientSecret: suspend (LinkAction) -> String = fetchClientSecret

/** Link, the onboarding feature: add a charger, change its network, set it up, reconnect it. */
/** Link, the onboarding feature: add a charger, set it up, reconnect it (or change its network). */
public val link: Link = Link(this)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,13 @@ class BridgeTest {
json(
"status" to "success",
"sessionId" to "ls_1",
"action" to "network",
"action" to "reconnect",
"devices" to JSONArray().put(json("type" to "charger", "id" to "42")).put(json("type" to "meter", "id" to "m1")),
),
openedAction = "network",
openedAction = "reconnect",
)
assertEquals(
LinkResult(LinkResult.Status.SUCCESS, "network", "ls_1", listOf(Device("charger", "42"), Device("meter", "m1"))),
LinkResult(LinkResult.Status.SUCCESS, "reconnect", "ls_1", listOf(Device("charger", "42"), Device("meter", "m1"))),
result,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ class InstancesTest {
seen += action
"cs_${action.name}_${action.chargerId}"
})
val action = LinkAction.network("42")
val action = LinkAction.reconnect("42")
val id = Instances.register(plugchoice, action)
try {
val secrets = ClientSecrets(this, Instances.clientSecretSource(id)!!)
secrets.prefetch()
assertEquals("cs_network_42", secrets.next())
assertEquals("cs_network_42", secrets.next())
assertEquals("cs_reconnect_42", secrets.next())
assertEquals("cs_reconnect_42", secrets.next())
assertEquals(2, seen.size)
seen.forEach { assertSame("the very action launched", action, it) }
} finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ class LinkDestinationTest {
fun `the URL carries the action and ids in the fragment`() {
assertEquals("https://connect.plugchoice.com/#action=add", resolve(LinkAction.addCharger()).url)
assertEquals("https://connect.plugchoice.com/#action=add&site_id=site_1", resolve(LinkAction.addCharger("site_1")).url)
assertEquals("https://connect.plugchoice.com/#action=network&charger_id=42", resolve(LinkAction.network("42")).url)
assertEquals("https://connect.plugchoice.com/#action=setup&charger_id=42", resolve(LinkAction.setup("42")).url)
assertEquals("https://connect.plugchoice.com/#action=reconnect&charger_id=42", resolve(LinkAction.reconnect("42")).url)
assertEquals(
Expand All @@ -42,7 +41,7 @@ class LinkDestinationTest {
@Test
fun `empty ids are left out`() {
assertEquals("action=add", LinkDestination.fragment(LinkAction.addCharger("")))
assertNull(LinkAction.custom("network", chargerId = "").chargerId)
assertNull(LinkAction.custom("reconnect", chargerId = "").chargerId)
}

@Test
Expand All @@ -58,8 +57,8 @@ class LinkDestinationTest {

@Test
fun `actions compare by value`() {
assertEquals(LinkAction.network("42"), LinkAction.custom("network", "42"))
assertEquals(LinkAction.network("42").hashCode(), LinkAction.custom("network", "42").hashCode())
assertEquals(LinkAction.reconnect("42"), LinkAction.custom("reconnect", "42"))
assertEquals(LinkAction.reconnect("42").hashCode(), LinkAction.custom("reconnect", "42").hashCode())
assertEquals("add", LinkAction.addCharger().name)
}

Expand All @@ -73,8 +72,8 @@ class LinkDestinationTest {
@Test
fun `the override replaces scheme, host and port in debug builds`() {
val ignored = mutableListOf<String>()
val destination = resolve(LinkAction.network("42"), "http://192.168.1.20:5173", ignored = ignored)
assertEquals("http://192.168.1.20:5173/#action=network&charger_id=42", destination.url)
val destination = resolve(LinkAction.reconnect("42"), "http://192.168.1.20:5173", ignored = ignored)
assertEquals("http://192.168.1.20:5173/#action=reconnect&charger_id=42", destination.url)
assertEquals("http://192.168.1.20:5173", destination.origin)
assertTrue(ignored.isEmpty())
}
Expand Down
2 changes: 1 addition & 1 deletion bridge/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ The host app creates one SDK instance with a callback that fetches a **client se

Before showing anything the shell:

1. Builds `https://connect.plugchoice.com/#action=<action>`, plus `&charger_id=<id>` and `&site_id=<id>` when given (and not empty), each value percent-encoded (everything but RFC 3986's unreserved characters). The action is an open string (`add`, `network`, `setup`, `reconnect`, or a later one) that the shell passes on without reading it. The URL never holds the secret.
1. Builds `https://connect.plugchoice.com/#action=<action>`, plus `&charger_id=<id>` and `&site_id=<id>` when given (and not empty), each value percent-encoded (everything but RFC 3986's unreserved characters). The action is an open string (`add`, `setup`, `reconnect`, or a later one) that the shell passes on without reading it. The URL never holds the secret.
2. Uses `https://connect.plugchoice.com` as the **only** allowed origin, for the bridge and for main-frame navigation (an explicit `:443` is the same origin).
3. **Debug override**: `options.hostOverride` (for example `http://192.168.1.20:5173`, a local copy of the page) replaces the scheme, host and port and becomes the only allowed origin. It must be `http` or `https`, a host and an optional port, with at most a trailing `/`. It is honoured only in debug builds of the host app (iOS: `#if DEBUG`, as the package builds from source with the app's configuration; Android: the app's `FLAG_DEBUGGABLE`), and otherwise ignored with a log line, as is a malformed one.
4. Calls the host's callback once with the action, in parallel with loading the page, and keeps the answer for the page's first `auth.clientSecret` (§7).
Expand Down
10 changes: 3 additions & 7 deletions ios/Example/Example/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ struct ContentView: View {
}

enum ActionKind: String, CaseIterable, Identifiable {
case add, network, setup, reconnect, custom
case add, setup, reconnect, custom
var id: String { rawValue }
}

Expand Down Expand Up @@ -180,16 +180,12 @@ struct ContentView: View {
return nil
}
return .custom(name, chargerId: chargerId.isEmpty ? nil : chargerId)
case .network, .setup, .reconnect:
case .setup, .reconnect:
guard !chargerId.isEmpty else {
inputError = "Enter the charger's id."
return nil
}
switch actionKind {
case .network: return .network(chargerId: chargerId)
case .setup: return .setup(chargerId: chargerId)
default: return .reconnect(chargerId: chargerId)
}
return actionKind == .setup ? .setup(chargerId: chargerId) : .reconnect(chargerId: chargerId)
}
}

Expand Down
13 changes: 5 additions & 8 deletions ios/Sources/PlugchoiceSDK/Plugchoice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public final class Plugchoice: Sendable {
///
/// - Parameters:
/// - action: What to do, such as `.addCharger()` or
/// `.network(chargerId:)`.
/// `.reconnect(chargerId:)`.
/// - presenter: The view controller to present from.
/// - completion: Called with the result.
@MainActor
Expand All @@ -120,7 +120,7 @@ public final class Plugchoice: Sendable {
/// open string; the hosted page decides what it means. `fetchClientSecret`
/// gets it too, so your server can scope the client secret.
public struct LinkAction: Sendable, Equatable, Hashable {
/// `add`, `network`, `setup`, `reconnect`, or a later one.
/// `add`, `setup`, `reconnect`, or a later one.
public let action: String
public let chargerId: String?
public let siteId: String?
Expand All @@ -136,17 +136,14 @@ public struct LinkAction: Sendable, Equatable, Hashable {
LinkAction(action: "add", siteId: siteId)
}

/// Change a charger's network connection.
public static func network(chargerId: String) -> LinkAction {
LinkAction(action: "network", chargerId: chargerId)
}

/// Set up a charger's electrical settings.
public static func setup(chargerId: String) -> LinkAction {
LinkAction(action: "setup", chargerId: chargerId)
}

/// Reconnect a charger to Plugchoice.
/// Get a charger already on Plugchoice reaching Plugchoice again. This
/// also changes its network (Wi-Fi and the like), so open it from a
/// "Network settings" button too.
public static func reconnect(chargerId: String) -> LinkAction {
LinkAction(action: "reconnect", chargerId: chargerId)
}
Expand Down
4 changes: 2 additions & 2 deletions ios/Tests/PlugchoiceSDKTests/BridgeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,12 @@ final class BridgeTests: XCTestCase {
let close = try close([
"status": "success",
"sessionId": "s1",
"action": "network",
"action": "reconnect",
"devices": [["type": "charger", "id": "c1"], ["type": "meter", "id": "m1"]],
])
XCTAssertEqual(close, Bridge.SessionClose(
status: .success,
action: "network",
action: "reconnect",
sessionId: "s1",
devices: [Device(type: "charger", id: "c1"), Device(type: "meter", id: "m1")],
error: nil
Expand Down
4 changes: 2 additions & 2 deletions ios/Tests/PlugchoiceSDKTests/ClientSecretsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ final class ClientSecretsTests: XCTestCase {

private struct Failure: Error {}

private let opened = LinkAction.network(chargerId: "c-1")
private let opened = LinkAction.reconnect(chargerId: "c-1")

private func secrets(_ backend: Backend, timeout: TimeInterval = ClientSecrets.defaultTimeout) -> ClientSecrets {
ClientSecrets(action: opened, fetch: { try await backend.fetch($0) }, timeout: timeout)
Expand Down Expand Up @@ -84,7 +84,7 @@ final class ClientSecretsTests: XCTestCase {
XCTAssertEqual([first, second, third], ["cs_test_1", "cs_test_2", "cs_test_3"])
XCTAssertEqual(backend.calls, 3)
XCTAssertEqual(backend.actions, [opened, opened, opened], "refreshes get the same action")
XCTAssertEqual(backend.actions.first?.action, "network")
XCTAssertEqual(backend.actions.first?.action, "reconnect")
XCTAssertEqual(backend.actions.first?.chargerId, "c-1")
}

Expand Down
18 changes: 8 additions & 10 deletions ios/Tests/PlugchoiceSDKTests/LinkTargetTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ final class LinkTargetTests: XCTestCase {
func testEveryActionAndItsIds() {
let cases: [(LinkAction, String)] = [
(.addCharger(siteId: "site-1"), "#action=add&site_id=site-1"),
(.network(chargerId: "c-1"), "#action=network&charger_id=c-1"),
(.setup(chargerId: "c-1"), "#action=setup&charger_id=c-1"),
(.reconnect(chargerId: "c-1"), "#action=reconnect&charger_id=c-1"),
(.custom("diagnose", chargerId: "c-1"), "#action=diagnose&charger_id=c-1"),
Expand Down Expand Up @@ -54,8 +53,8 @@ final class LinkTargetTests: XCTestCase {
// MARK: - hostOverride

func testOverrideReplacesSchemeHostAndPortInDebug() {
let target = LinkTarget(action: .network(chargerId: "c-1"), hostOverride: "http://192.168.1.20:5173", honourOverride: true)
XCTAssertEqual(target.url.absoluteString, "http://192.168.1.20:5173/#action=network&charger_id=c-1")
let target = LinkTarget(action: .reconnect(chargerId: "c-1"), hostOverride: "http://192.168.1.20:5173", honourOverride: true)
XCTAssertEqual(target.url.absoluteString, "http://192.168.1.20:5173/#action=reconnect&charger_id=c-1")
XCTAssertEqual(target.allowedOrigin.description, "http://192.168.1.20:5173")
let allowList = OriginAllowList([target.allowedOrigin])
XCTAssertTrue(allowList.allows(url: target.url))
Expand Down Expand Up @@ -95,12 +94,11 @@ final class LinkTargetTests: XCTestCase {
func testActions() {
XCTAssertEqual(LinkAction.addCharger(), LinkAction(action: "add"))
XCTAssertEqual(LinkAction.addCharger(siteId: "s"), LinkAction(action: "add", siteId: "s"))
XCTAssertEqual(LinkAction.network(chargerId: "c"), LinkAction(action: "network", chargerId: "c"))
XCTAssertEqual(LinkAction.setup(chargerId: "c"), LinkAction(action: "setup", chargerId: "c"))
XCTAssertEqual(LinkAction.reconnect(chargerId: "c"), LinkAction(action: "reconnect", chargerId: "c"))
XCTAssertEqual(LinkAction.custom("x", chargerId: "c"), LinkAction(action: "x", chargerId: "c"))
let network = LinkAction.network(chargerId: "c")
XCTAssertEqual([network.action, network.chargerId, network.siteId], ["network", "c", nil])
let reconnect = LinkAction.reconnect(chargerId: "c")
XCTAssertEqual([reconnect.action, reconnect.chargerId, reconnect.siteId], ["reconnect", "c", nil])
}

func testTransportsInTheTestHost() {
Expand All @@ -125,9 +123,9 @@ final class LinkTargetTests: XCTestCase {
func testAScreenClosedByTheHostReportsTheOpenedAction() throws {
let plugchoice = Plugchoice(fetchClientSecret: { _ in "cs_test_secret" })
var result: LinkResult?
let controller = plugchoice.link.makeController(.network(chargerId: "c-1")) { result = $0 }
let controller = plugchoice.link.makeController(.reconnect(chargerId: "c-1")) { result = $0 }
controller.closeForHost()
XCTAssertEqual(result, LinkResult(status: .cancelled, action: "network"))
XCTAssertEqual(result, LinkResult(status: .cancelled, action: "reconnect"))
}

@MainActor
Expand All @@ -137,8 +135,8 @@ final class LinkTargetTests: XCTestCase {
LinkResult(status: .cancelled, action: "add", sessionId: nil, devices: [], error: nil)
)
XCTAssertEqual(
LinkViewController.ownResult(action: "network", loadErrorMessage: "offline"),
LinkResult(status: .error, action: "network", error: LinkError(code: "pageLoadFailed", message: "offline"))
LinkViewController.ownResult(action: "reconnect", loadErrorMessage: "offline"),
LinkResult(status: .error, action: "reconnect", error: LinkError(code: "pageLoadFailed", message: "offline"))
)
}

Expand Down
Loading
Loading