# Integrate the mobile SDK

What the iOS and Android SDK can do, what your application remains responsible for, how to install it today, and why it is not yet distributable to an outside team.

Source: https://docs.sealtrust.io/en/sdk-mobile/

---

The mobile SDK is a typed client of the public SealTrust API, plus three
ready-to-place screens, for iOS and for Android. By the time you leave this
page, you will know what it can do, what it will never do, what your
application must remain responsible for, how to wire it in, and what state of
distribution it is in today.

Let us start with that state, because it changes the answer to the question
"can I use it this week".

## Where this SDK stands today

Both platforms are at version `0.1.0` and are published to no package
repository.

On the iOS side, the Swift package manifest is not at the root of a git
repository. The Swift package manager only resolves a package from a git
address in that case. The `.package(url:)` declaration therefore does not work,
and only the local path declaration works.

On the Android side, the two modules do declare Maven coordinates,
`io.sealtrust:sealtrust-core` and `io.sealtrust:sealtrust-ui`, at version
`0.1.0`. The publication configuration declares no remote repository. The only
possible publication is therefore local, on your own machine.

## How the work is divided

The SDK does not read the chip. That is the first thing to understand, because
it determines everything else.

A SealTrust NFC chip proves its authenticity by computing a fresh signature at
each read, with a key of its own. Verifying that signature requires the brand's
chip keys, and those keys do not leave our servers.

| Who | Does what |
| --- | --- |
| The phone system | Reads the address the chip emits, or the printed QR code. |
| Your application | Owns the NFC session, the camera, the user sign-in. |
| The SDK | Reads that address, calls the API, turns the response into a displayable verdict. |
| The SealTrust API | Verifies the cryptography of the chip and replies. |

Three concrete consequences.

The SDK requests no NFC permission and no location permission. It is the
Android screens module that declares the internet access permission, and that
is the only one it declares. The client module alone declares no permission.

Offline verification does not exist. Every check is a network call.

The SDK contains no secret. There is no mobile API key, no signing key, no
embedded identifier. The only thing it can carry is the session token of a user
already signed in by you. It sends it on ownership claim calls, and on passport
reads when that token is available.

## A product can carry two codes, and they do not prove the same thing

The SDK names which of the two was read. The handling then differs for each.

| What was scanned | What it proves | What you call |
| --- | --- | --- |
| The NFC chip | The physical object was there. The chip recomputes a fresh signature at each read. | `verifyScan` |
| The printed QR code | Which item it is. A photograph of the label gives back the same string. | `passport`, `certificate`, `history`, and `requestClaimTicket` from the serial number |

Many products carry only a QR code. The platform serves that mode as a mode in
its own right. The passport, the certificate and the history are then read
normally, from the printed number alone. What is not provided is the proof of
physical presence.

The address reading function tells you which of the two you have in hand, and
returns an empty value when the address is neither. That last case happens
often in production: a customer who points your scanner at the GS1 link of
another brand deserves a clean answer.

:::onglets
```swift
import SealTrustKit

func traiter(_ chaineScannee: String, avec client: SealTrustClient) async throws {
    switch ScanURL.read(chaineScannee) {
    case .chip(let preuve):
        let verification = try await client.verifyScan(preuve)
        print(verification.productName ?? "")
    case .printedSerial(let numeroDeSerie):
        let passeport = try await client.passport(identifier: numeroDeSerie)
        print(passeport.productName ?? "")
    case nil:
        print("Ce code n'est pas un code SealTrust.")
    }
}
```
```kotlin
import io.sealtrust.sdk.core.ScanUrl
import io.sealtrust.sdk.core.ScannedCode
import io.sealtrust.sdk.core.SealTrustClient

// The client blocks: call it off the main thread.
fun traiter(chaineScannee: String, client: SealTrustClient) {
    when (val code = ScanUrl.read(chaineScannee)) {
        is ScannedCode.Chip -> println(client.verifyScan(code.proof).productName)
        is ScannedCode.PrintedSerial -> println(client.passport(code.serial).productName)
        null -> println("Ce code n'est pas un code SealTrust.")
    }
}
```
:::

The printed QR code comes in two forms: `/p/{serial}` and the GS1 form
`/01/{gtin}/21/{serial}`. A `/01/{gtin}` link with no serial number names a
product reference, so the read returns no serial number. This is not a dead
end: a reference has its own passport, the model's, and the client reads it
with `referencePassport`. Keep the distinction in mind, a reference passport
describes a model and proves nothing about the object you are holding.

The passport is served at two levels only: the model, by its GTIN product code,
and the item, by its identifier. A model passport covers every unit that shares
the same product code. The ESPR regulation also allows a batch passport, which
the platform does not serve today.

The serial number is returned exactly as it is printed, never rewritten. It is
the server that applies the normalization rules. A published application does
not update at the same pace as the server, and a client that applied its own
rules would answer "product not found" on a valid label the day the two sets of
rules diverged.

## Platforms and minimum versions

| Characteristic | iOS | Android |
| --- | --- | --- |
| Minimum version | iOS 16 | API level 26 |
| Distribution | Swift package `SealTrustSDK` | Gradle project, two modules |
| Client | `SealTrustKit` library | `sealtrust-core` module |
| Screens | `SealTrustUI` library | `sealtrust-ui` module |
| Client dependencies | None, Foundation alone | None |
| Screen dependencies | SwiftUI alone | Jetpack Compose and `androidx.lifecycle:lifecycle-viewmodel-compose` |

The Android module of the client is an ordinary Kotlin module, with nothing
Android inside. It compiles to Java 17. The screens module targets compile
level 35 and minimum level 26, the latter being imposed by the reading of
dates. Go lower only with the core library desugaring active in your
application.

## Installing

> [!DANGER] This SDK is not fetched from a public repository
> Write to us to obtain access to the sources, or call the public API directly.
> The two installation paths below assume that you already have the sources on
> your machine.

:::onglets
```swift
// Package.swift of your application.
// Replace the path with the one of your local copy of the sources.
dependencies: [
    .package(name: "SealTrustSDK", path: "<chemin-vers-les-sources>/ios"),
],
targets: [
    .target(
        name: "VotreApplication",
        dependencies: [
            .product(name: "SealTrustKit", package: "SealTrustSDK"),
            .product(name: "SealTrustUI", package: "SealTrustSDK"),
        ]
    ),
]
```
```kotlin
// settings.gradle.kts of your application.
// Replace the path with the one of your local copy of the sources.
includeBuild("<chemin-vers-les-sources>/android")

// build.gradle.kts of your module.
dependencies {
    implementation("io.sealtrust:sealtrust-core")
    implementation("io.sealtrust:sealtrust-ui")
}
```
:::

The `name:` parameter is mandatory on iOS. A package declared by path takes the
name of its directory as its identity. Without this parameter, the Swift
package manager does not find `SealTrustSDK` and the build fails.

On Android, a second route exists if the composite build does not suit you.
From the directory of the SDK's Gradle project, `./gradlew
publishToMavenLocal` installs the two modules into your local Maven repository.
You then consume them as versioned dependencies.

```kotlin
repositories {
    mavenLocal()
}

dependencies {
    implementation("io.sealtrust:sealtrust-core:0.1.0")
    implementation("io.sealtrust:sealtrust-ui:0.1.0")
}
```

If you only want the client, take `SealTrustKit` alone, or `sealtrust-core`
alone. You then pay nothing for the screens.

One consequence on Android: it is the screens module that declares the internet
access permission. If you take `sealtrust-core` alone, declare
`android.permission.INTERNET` in your own manifest.

## Creating the client

The client is built with a configuration. Its four values:

| Value | Default | Role |
| --- | --- | --- |
| Base address | `https://api.sealtrust.io` | To be replaced to target another environment or your own relay. |
| Timeout | 15 seconds | Connection and read timeout. |
| Client identifier | `sealtrust-sdk-ios/0.1.0` or `sealtrust-sdk-android/0.1.0` | Sent in the `X-SealTrust-Client` header. |
| Token provider | absent | Returns the session token of the signed-in user, or nothing. |

The SDK signs nobody in. Ownership claim calls require an authenticated user,
so your application provides the token. Return an empty value when nobody is
signed in: the SDK then fails with an authentication error instead of sending a
request that can only come back as a 401.

:::onglets
```swift
import Foundation
import SealTrustKit

enum SessionApplication {
    /// Token of the signed-in user, fed by your own sign-in.
    static var jeton: String?
}

let client = SealTrustClient(
    configuration: SealTrustConfiguration(
        accessTokenProvider: { SessionApplication.jeton }
    )
)
```
```kotlin
import io.sealtrust.sdk.core.SealTrustClient
import io.sealtrust.sdk.core.SealTrustConfiguration

object SessionApplication {
    /** Token of the signed-in user, fed by your own sign-in. */
    @Volatile
    var jeton: String? = null
}

val client = SealTrustClient(
    SealTrustConfiguration(accessTokenProvider = { SessionApplication.jeton }),
)
```
:::

Two headers accompany every call: `Accept: application/json` and
`X-SealTrust-Client`. Calls that carry a body add
`Content-Type: application/json`.

The `Authorization` header is added on claim calls, and on passport reads when
your application has provided a token. A signed-in owner then sees their read
widened.

One difference in style between the two platforms, not to be missed. On iOS,
every method that calls the network is asynchronous. Building the address of
the certificate in PDF is synchronous, because it calls nothing. On Android,
the methods are blocking: call them off the main thread. The three Android
screens already do it for you.

## Verifying a scan

This is the central call. You give it the address read on the chip, it returns
a verdict.

:::onglets
```swift
import SealTrustKit

func verifier(_ adresse: URL, avec client: SealTrustClient) async -> ScanOutcome {
    do {
        return ScanOutcome.from(try await client.verifyScan(url: adresse))
    } catch {
        return ScanOutcome.from(error)
    }
}
```
```kotlin
import io.sealtrust.sdk.core.ScanOutcome
import io.sealtrust.sdk.core.SealTrustClient

fun verifier(adresse: String, client: SealTrustClient): ScanOutcome = try {
    ScanOutcome.of(client.verifyScan(adresse))
} catch (erreur: Throwable) {
    ScanOutcome.of(erreur)
}
```
:::

> [!ATTENTION] Pass the address on as you read it
> The parameters of the chip are part of what it signed. Do not normalize them,
> do not uppercase them, do not invent any. A rewritten address produces a
> signature that no longer matches, therefore a counterfeit warning on a sound
> product. The SDK passes these parameters on as they are, do the same
> upstream.

The verdict takes eight forms, and telling them apart matters for the user.

| Verdict | What happened | What the screen must say |
| --- | --- | --- |
| Authentic | The chip returned a signature that the server validated. | Authentic product. |
| Replay | This exact address has already been presented. | Alert. This is the signal that a label may have been copied. |
| Invalid signature | The signature does not match this chip. | This is not a SealTrust chip, or it is a copy. |
| Minting in progress | Authentic chip, its certificate is being written. | Ask the user to try again in a moment. |
| Never registered | Authentic chip, nothing has ever been registered for it. | The anomaly is on the brand's side. The buyer has nothing to correct. |
| Unreadable link | The address carried no usable proof, or the API refused the parameters. | Invite a rescan. |
| Too many calls | The per IP address limit is reached. | Invite the user to try again in a minute. |
| Failure | Everything else, including the network being unreachable. | Technical error, with no authenticity verdict. |

An unrecognized conflict is read as a replay. The SDK therefore reports the
anomaly to the reader.

## The electronic seal and the theft report

Two pieces of information travel with the verification and deserve particular
handling.

The Tag Tamper seal is a three-value state: never opened, opened, or nothing to
say. A standard chip and a chip whose seal has never been activated both return
the third value, on authentic products. Never display the absence of
information as a broken seal. The SDK exposes a property that combines this
state with the policy chosen by the brand, and that returns an empty value as
soon as there is nothing to show. Three policies exist: silence, information,
and degraded displayed trust. The default value is information.

> [!INFO] A broken seal is not a counterfeit
> The customer who unpacks their box breaks the seal, and every second-hand
> product has broken it. Cryptographic authenticity remains the one given by
> the scan verdict.

A theft report open on a unit does not make the verification fail. The scan
replies normally, and a boolean carries the warning. Display it, otherwise it
is said nowhere.

## Reading a passport, its proofs and its certificate

The client exposes seven reads, all public.

| Call | What it returns |
| --- | --- |
| `passport` | The published digital passport of a unit. |
| `passportProof` | The bundle of proofs of that passport. |
| `referencePassport` | The passport of the model, from a GTIN. |
| `referencePassportProof` | The bundle of proofs of the model. |
| `certificate` | The public certificate of authenticity. |
| `certificatePDFURL` on iOS, `certificatePdfUrl` on Android | The address of the same certificate in PDF. Nothing is called, the address is built. |
| `history` | Ownership movements and authentication scans, from the most recent to the oldest. |

The passport is read at an access tier. The SDK exposes the six values the API
accepts: `public`, `end_user`, `repairer`, `recycler`, `upstream` and
`authority`. The three trade levels are distinct audiences. None of them contains the
others: holding the repairer accreditation does not open the recycler's. The default is `public`. The higher tiers are controlled by
the server, which replies 401 or 403 to a caller who has no right to them. The
tier actually served appears in the response, and it is not always the one
requested: a signed-in owner sees their public read widened automatically.

:::onglets
```swift
let passeport = try await client.passport(
    identifier: "0x0000000000000000000000000000000000000000000000000000000000000000"
)
print(passeport.accessTier, passeport.productName ?? "")

let preuves = try await client.passportProof(
    identifier: "0x0000000000000000000000000000000000000000000000000000000000000000"
)
print(preuves.dataHash ?? "", preuves.ipfsURI ?? "")

let modele = try await client.referencePassport(gtin: "03701234567890")
print(modele.productName ?? "")
```
```kotlin
val passeport = client.passport(
    "0x0000000000000000000000000000000000000000000000000000000000000000",
)
println("${passeport.accessTier} ${passeport.productName}")

val preuves = client.passportProof(
    "0x0000000000000000000000000000000000000000000000000000000000000000",
)
println("${preuves.dataHash} ${preuves.ipfsUri}")

val modele = client.referencePassport("03701234567890")
println(modele.productName)
```
:::

The bundle of proofs carries the fingerprint of the content, the copy on IPFS,
the anchoring state on Base and the state of the verifiable certificate. The
anchoring state takes three distinct values: anchored, never anchored, and
anchored then modified. The third is the only one this mechanism exists to
raise. Never display it as a "not anchored".

It takes a fourth form, an empty value, when the server served no anchoring
block. Treat this case separately and never display this empty value as a "not
anchored". It means that you have no information on the anchoring of this
product. Anchoring on Base is an operation that SealTrust triggers by hand, and
most products are never anchored. A brand cannot trigger it itself.

## Claiming ownership of a product

The journey registers a scanned product in the name of the signed-in user. It
fits in two calls, plus one state read.

The first call requests a ticket from the read of the chip. A ticket is issued
only as long as the product belongs to nobody.

A success response with no ticket is normal. The detail field then says why:
already claimed, already transferred, unknown identifier, competing claim in
progress, or purchase code required.

:::onglets
```swift
import SealTrustKit

func revendiquer(_ chaineScannee: String, avec client: SealTrustClient) async throws {
    guard case .chip(let preuve) = ScanURL.read(chaineScannee) else {
        print("Ce code n'est pas une lecture de puce.")
        return
    }
    let demande = try await client.requestClaimTicket(for: preuve)
    guard let ticket = demande.ticket else {
        print(demande.detail ?? "Aucun ticket délivré.")
        return
    }
    let resultat = try await client.completeClaim(ticket)
    print(resultat.txHash ?? "")
}
```
```kotlin
import io.sealtrust.sdk.core.ScanUrl
import io.sealtrust.sdk.core.ScannedCode
import io.sealtrust.sdk.core.SealTrustClient

// The client blocks: call it off the main thread.
fun revendiquer(chaineScannee: String, client: SealTrustClient) {
    val code = ScanUrl.read(chaineScannee)
    if (code !is ScannedCode.Chip) {
        println("Ce code n'est pas une lecture de puce.")
        return
    }
    val demande = client.requestClaimTicket(code.proof)
    val ticket = demande.ticket
    if (ticket == null) {
        println(demande.detail ?: "Aucun ticket délivré.")
    } else {
        val resultat = client.completeClaim(ticket)
        println(resultat.txHash)
    }
}
```
:::

The second call turns the ticket into ownership recorded on the chain. It
requires the token of the signed-in user. The response is written as soon as
the transfer is broadcast, before it is engraved, so the transaction hash it
carries designates a pending operation.

Some brands hand over a purchase code with the product. Only ask the user for
one after the API has replied a first time that it is required. The customers
of brands that issue none will then never see this field.

The ticket carries a nonce. It is the proof that somebody physically read the
label. Keep it in memory, do not log it, do not store it.

> [!DANGER] Two 502 errors look alike and say the opposite
> `TRANSFER_NOT_SENT` means that nothing moved on the chain: trying again is
> safe. `TRANSFER_SENT_PENDING` means that the transfer was indeed broadcast
> and that only the writing of the tracking record failed. Trying again then
> asks the user for a second operation on the chain. The two screens of the SDK
> display this second case as "come back in a moment", never as a retry button.

A third call reads the state of a ticket: issued, consumed, expired or canceled.
It requires the user's token as well. The server returns this state as it is,
without filtering. Treat it as an open string and plan a default display for a
value you do not know.

There is a variant that requests a ticket from the printed serial number,
without a chip. It is authenticated, and it works only on the very first claim
of a product: a product already owned or already claimed is refused with a 409.
A printed number proves much less than a read of a chip, and this lock is the
consequence. It is not open on every account: if it is not open on yours, it
replies 404, and that is the only reading to make of this code on this call.
Check with us that it is open for your application before integrating it.

## The three ready-to-place screens

The interface module provides three screens built on the client. They exist in
SwiftUI and in Compose, with the same parameters.

| Screen | What it takes | What it gives back to your application |
| --- | --- | --- |
| Verification | The client, the scanned address or the proof, optional coordinates, the texts. | Two callbacks: open the passport, or start the claim. |
| Claim | The client, the proof, a boolean saying whether a user is signed in, the product name, the texts. | Two callbacks: ask for sign-in, or announce that the claim is done. |
| Passport | The client, the identifier, the access tier, the texts. | Nothing, it is a reading screen. |

The claim screen does not start your sign-in. You tell it whether a user is
signed in, and it asks for sign-in instead of triggering a call that can only
come back as a 401.

All visible texts can be overridden field by field. A complete English set and
a complete French set are provided, and no visible sentence is frozen inside a
screen.

The colors are fields of a theme and are replaced one by one. The three screens
have only one appearance: the theme carries fixed colors and does not react to
the dark mode of the system.

## The errors

The SDK brings everything back to five cases.

| Case | When |
| --- | --- |
| Invalid scan address | The link carried no usable proof. |
| Not authenticated | An authenticated call was attempted without a session token. |
| API error | The API replied with a 4xx or a 5xx. The case carries the HTTP code, the detail, and the machine code when the API prefixes one. |
| Decoding | The body of the response does not have the expected shape. |
| Transport | The request never got through: DNS, TLS, timeout, no network. |

The machine code is extracted from the detail when the detail starts with an
uppercase word containing at least one underscore, for example `MINT_PENDING`
or `CLAIM_EXPIRED`. An uppercase word without an underscore is never extracted.
The machine code is an empty value when the API replies with a free sentence.

## Call limits

The calls of the SDK target public endpoints, whose limits are counted per IP
address, over a window of 60 seconds.

| Endpoint | Limit |
| --- | --- |
| Scan verification | 30 per minute |
| Ticket request from a scan | 30 per minute |
| Passport and proofs | 60 per minute |
| Certificate | 60 per minute |
| Product history | 30 per minute |
| Claim | 20 per minute |

Going over comes back as a 429, and the SDK translates it into the "too many
calls" verdict on the verification path.

## Coordinates and privacy

Coordinates are optional on scan verification. Both parameters are an empty
value by default, on both platforms: an integrator who never passes them
transmits nothing.

They serve the anti-fraud checks on the server side. Only pass them with the
consent of the user.

The SDK reads no position, requests no location permission and does not check
that you obtained anything. The consent, its record and its mention in your
privacy policy are your obligation as the publisher of the application.

What the response gives back is a scan area at city level, in the form of a
city name and a country code, and only when you have provided coordinates. The
response contains no coordinates.

On iOS, the package embeds an Apple privacy manifest. It declares the absence
of tracking and two types of collected data.

Precise location, only when your application passes coordinates.

The user identifier, that is, the session token. The manifest says that it is
sent only on `POST /claims/from-serial`, on `POST /claims/complete`, on
`GET /claims/{id}`, and on `GET /passport/{id}` when a token is available. Take
this list as it is if you fill in the App Store privacy declaration.

On Android, nothing equivalent is declared inside the module. It is the
publisher of the application who fills in the data safety form of the store,
and they must cover there the data collected by every embedded library, this
one included.

## What is not in the SDK

- Ownership transfer between two people. It requires a one-time code and a
  matching scan on both sides. That is another journey.
- Product minting and destruction. These are authenticated operations, they are
  carried out from the console.
- Reading chips, scanning QR codes, loading images, signing users in. Your
  application already owns all of that, and the SDK deliberately embeds no
  dependency to do them.
