Dart / Flutter

The reference for the keygrant Flutter plugin 0.1.0, the one KeyGrant SDK for phones and tablets. Its first version has every feature on this page. The rules every SDK shares are in How every SDK behaves.

  • Flutter 3.47 or later; it is built and tested with 3.47.7.
  • Runs on iOS and iPadOS 15, Android 7 (API 24), macOS 12, Windows 10 and Linux, or later.
  • Depends on cryptography (Ed25519), crypto (SHA-256), http, ffi, meta and path_provider.

Release notes

Add it

Terminal
flutter pub add keygrant

The plugin registers itself. An isolate your app starts itself (a background task) calls DartPluginRegistrant.ensureInitialized() before it makes a license there.

Use it

Dart
import 'package:keygrant/keygrant.dart';
final license = KeyGrantLicense(LicenseConfig( product: 'my-product', apiBaseUrl: 'https://api.keygrant.dev', publicJwks: parseJwkSet(keySet), // the {"keys": [...]} from /pubkey major: 2,));
final status = await license.status();if (status.state == LicenseState.unlicensed) { final result = await license.activate(enteredKey); if (result case ActivateFailed(:final error)) showError(error.wire);}
  • Every call returns a Future.
  • Make one KeyGrantLicense per app and share it. Its calls run one at a time, except has, require, upgradeUrl, pickUp and confirmPickUp, which only read and run beside any call.
  • Results are sealed classes, so a switch covers every case. Every error is an enum whose wire is the server's word, as How every SDK behaves writes it (LicenseError.invalidKey.wire is invalid-key).

LicenseConfig

product

Required. The product's API ID.

apiBaseUrl

Required. Set it to https://api.keygrant.dev.

publicJwks

The product's key set, a list of JWKs. parseJwkSet(text) reads what /pubkey answers, a list of JWKs or one JWK. Give this or publicJwk.

publicJwk

One public key (a JWK): a key set of one. Give this or publicJwks.

major

The running app's major version. A 0.x build sets 0 and runs as major 1, and so does anything below it.

Default 1

deviceName

What the license's device list calls this device, cut to 120 UTF-16 units.

deviceHold

false on pooled, non-persistent machines: no lease is then held to its device. See Device management.

Default true

httpTimeout

A Duration: how long any one request may take. The SDK holds every request to it itself.

Default 15 seconds

revokedLicenseFiles

The ids of the license files this build refuses, matched exactly (License files).

Default []

adapters

A LicenseAdapters(storage:, registry:, clock:, fingerprinter:, http:), any of them null for the default.

Calls

activate(key, {email, code})

Returns Activated or ActivateFailed(error), a LicenseError (below). The key is trimmed and upper-cased first.

  • email and code are what a person typed for a key only its buyer can activate (below).
  • It throws only when the stored license cannot be read or saved.
status({forceOnline = false})

Returns a LicenseStatus (below), offline first, and never throws.

refresh()

status() forced online: call it when the customer says they have paid. Never throws.

startTrial()

Starts this device's trial and returns its LicenseStatus. Never throws.

deactivate()

Returns a DeactivateResult with released (Deactivation).

has(code)

Whether the license grants the entitlement code, offline, as status() judges it with no ask and no write. See Check an entitlement.

require(code)

Like has(code), but throws MissingEntitlement, which names code, when the license does not grant it.

importLicenseFile(text, {email, code})

Takes a .kglicense file's content as text. Returns an ImportLicenseFileResult (below).

  • importLicenseFileBytes(bytes, {email, code}) takes the file's bytes, as a drop handler or an open dialog reads them.
removeLicenseFile()

Drops the stored offline license file. A key the device holds keeps working.

use(meter, [n = 1])

Returns UseSpent(left, offline) or UseRefused(error, left), and never throws (Metered uses).

pickUp(email)

Emails a six-digit code to the address, for key pickup. Returns PickUpCodeSent or PickUpSendFailed(error, retryAfter).

confirmPickUp(email, code)

Returns PickUpKeysFound(keys), the address's keys of the product, or PickUpKeysFailed(error, retryAfter).

restoreByEmail(email, code)

Confirms the code and activates the key that fits this build. Returns Restored or RestoreFailed(error, retryAfter).

  • Like activate(), it throws only when the stored license cannot be read or saved.
upgradeUrl(paymentLink)

Returns the link with this device's activation id as client_reference_id (never the key), or null when no key and activation are stored. See Sell an upgrade.

  • Otherwise it throws an ArgumentError for a link that is not an absolute URL.

LicenseStatus

state, reason

A LicenseState and, on expired, invalid and unknown, a LicenseReason (below).

key

The license key; on a status from a license file, the key the file records.

trialDaysLeft, trialEndsAt

On a trial: the days left, and when it ends, in Unix milliseconds.

test

true for a test license.

entitlements

A map of what the license grants: on licensed from the lease or file, on trial from the trial's lease; empty on every other status.

meters

A map of MeterStatus by meter code, on licensed from a key's lease only; empty otherwise (below).

source, licensedTo

LicenseSource.file and who the file is licensed to, when the status rests on a stored offline license file.

stalled

true when an earlier call has not finished, so the status was read from storage, with no ask and no write.

States and reasons

LicenseState and LicenseReason are enums whose wire is the server's word. What each means is in States and reasons. The names are the same but for two reasons: unknownKey (unknown-key) and revokedFile (revoked-file).

  • On invalid with reason upgrade, sell an upgrade (upgradeUrl).
  • On unknown with reason storage, keep running and check again shortly; never show the activation screen for it.

What to tell the customer for each reason is in Offline validation & grace.

Activation errors

ActivateFailed.error is a LicenseError: one of the activation errors, or one of the seven below. The names are the same but for invalidKey (invalid-key) and invalidLease (invalid-lease: a lease that does not verify here, a configuration error).

What throws

status(), refresh(), startTrial(), has(), use(), pickUp() and confirmPickUp() never throw. The others throw:

LicenseConfigError

The KeyGrantLicense constructor, for a configuration it cannot use: no public key, both publicJwk and publicJwks, or a key that is not an Ed25519 public key.

LicenseCallUnfinished

deactivate(), importLicenseFile() and removeLicenseFile(), behind a call that has not finished.

the storage's error

activate() and restoreByEmail(), when the stored license cannot be read or saved. deactivate(), importLicenseFile() and removeLicenseFile(), when it cannot be saved.

MissingEntitlement

require(code), when the license does not grant code.

ArgumentError

upgradeUrl, for a link that is not an absolute URL.

Keys only their buyer can activate

When a key is locked to its buyer (Who can activate), pass what the customer typed as activate(key, email: email, code: code). Neither is stored. The steps and rules are in How every SDK behaves.

Dart
final result = await license.activate(key, email: email, code: code);switch (result) {  case Activated():    showApp();  case ActivateFailed(error: LicenseError.userEmailRequired || LicenseError.userEmailMismatch):    askForTheAddress(); // then activate again with it  case ActivateFailed(error: LicenseError.userCodeRequired || LicenseError.userCodeInvalid):    askForTheCode(); // a code was just mailed to the address  case ActivateFailed(error: LicenseError.tooManyCodes || LicenseError.tooManyAttempts):    askToTryLater();  case ActivateFailed(error: LicenseError.tryAgain):    askToRetry(); // nothing was activated  case ActivateFailed(:final error):    showError(error.wire);}

importLicenseFile and importLicenseFileBytes take the same email and code for a key file's key. The LicenseError values a locked key answers:

userEmailRequired

The key is locked to its buyer and this device is new: ask for the email the key was sold to and activate again with it.

userEmailMismatch

That is not the email the key was sold to (or it is longer than 254 UTF-16 units, and no request is made). KeyGrant never says what the right one is.

userCodeRequired

The email was right and KeyGrant has just emailed a code to it: ask for the code and activate again with the email and the code.

userCodeInvalid

The code is wrong, expired (it lasts 10 minutes) or already used, or is not six digits (no request is made). Ask again, or activate with the email alone for a new code.

tooManyCodes

Three codes have been emailed for this key this hour. Use the last one, or wait.

tooManyAttempts

Too many wrong emails or codes for this key this hour. Try again later.

tryAgain

The code could not be emailed just now, and nothing was activated. Try again.

Restoring a license without the key

For an App Store build, which may not take a typed key: pickUp(email) emails the customer a six-digit code, and restoreByEmail(email, code) activates the key that fits this build. How a pickup works is in How every SDK behaves.

Dart
// 1. The customer types the address they bought with.final sent = await license.pickUp(email);if (sent case PickUpSendFailed(:final error, :final retryAfter)) {  return showError(error, retryAfter);}
// 2. They type the six-digit code that came.switch (await license.restoreByEmail(email, code)) { case Restored(): await license.status(); // licensed case RestoreFailed(:final error): showError(error); // wrongCode, codeExpired, noKey, upgrade, outdated, limit, ...}
  • To let the customer choose among several keys, call confirmPickUp(email, code), show each PickedKey by its modelName, and activate the one chosen with activate(picked.key, email: email, code: picked.activationCode).
  • RestoreFailed.error is a RestoreError: a pickup error below, noKey, or an activation error (upgrade and outdated when the address has keys, none for this build).
  • The requests are on the endpoints page.

The PickupError values are the pickup errors, named invalidEmail, wrongCode, codeExpired, tooManyAttempts, pickupDisabled, tryAgain and network.

License files

A customer can be sent a .kglicense file: a key file, which activates its key, or an offline license file, which licenses the app with no network. How each behaves is in How every SDK behaves, and what each costs under Delivered as in License models.

Warning

Keep every key that signed a file in publicJwks. A build without it reads such a file as invalid with reason unknownKey.

  • licenseFileExtension is .kglicense, licenseFileMediaType is application/vnd.keygrant.license, and maxLicenseFileBytes is 16,384.
  • The calls take the file's content, never its path: importLicenseFile(text) or importLicenseFileBytes(bytes).
  • List the ids of leaked files in revokedLicenseFiles (Blocking a leaked file).
Dart
import 'dart:io';import 'package:keygrant/keygrant.dart';
final license = KeyGrantLicense(LicenseConfig( product: 'sluice', apiBaseUrl: 'https://api.keygrant.dev', publicJwks: parseJwkSet(keySet), major: 3, revokedLicenseFiles: const [], // ids of files that leaked: an update adds them here));
// A path from your open dialog or drop target: the SDK takes the content, never the path.Future<void> importFrom(String path) async { if (!path.toLowerCase().endsWith(licenseFileExtension)) return; try { final file = File(path); // Never more than a license file can be: a larger one is refused unread. if (await file.length() > maxLicenseFileBytes) { showError(LicenseFileError.malformed.wire); return; } switch (await license.importLicenseFileBytes(await file.readAsBytes())) { case LicenseFileImported(:final licensedTo): showThanks(licensedTo.name ?? licensedTo.email); case LicenseFileRefused(:final error): showError(error.wire); // the file was not taken case LicenseFileActivationFailed(:final error): showError(error.wire); // a key file's key did not activate } } catch (_) { showRetry(); // unreadable, not saved, or a license call still running: try again }}

What importLicenseFile answers

It verifies the file offline (Importing a file) and returns one of:

  • LicenseFileImported(kind, licensedTo), where kind is LicenseFileKind.key or LicenseFileKind.offline;
  • LicenseFileRefused(error), a LicenseFileError below, with nothing stored;
  • LicenseFileActivationFailed(error), for a key file whose key did not activate: what activate() answers.
malformed

Not a license file's text, or its token or claims are broken, or it is more than maxLicenseFileBytes.

notALicenseFile

A signed token, but not a license file (a lease, say).

badSignature

Not signed by the key it names.

unknownKey

Signed by a key this build does not carry: a newer one, after the product rotated its key, which an update of the app carries.

wrongProduct

Another product's file.

revokedFile

The file's id is listed in revokedLicenseFiles.

upgrade

An offline file that does not cover this build: this build is newer than the file covers.

outdated

An offline file that does not cover this build: this build is older than the file covers.

The status of a stored file

A stored offline file answers status() with licensed, source LicenseSource.file, the key, licensedTo and the file's entitlements, and never asks KeyGrant.

  • Show "Licensed to Ada Lovelace" from licensedTo.
  • A status from a file has no seat to deactivate: offer removeLicenseFile() instead.
  • Which wins when the device also holds a key, and the reasons a file's status can be invalid, are in The status of a stored file.

Metered uses

A license model can count uses of what it sells (Metered uses). The app spends them with use(meter, n), before the work they count, and does the work only on UseSpent.

Dart
final spent = await license.use('exports'); // use('exports', 40) spends forty at once, whole or noneswitch (spent) {  case UseSpent(:final left, :final offline):    runExport();    showUsesLeft(left, offline); // left: what is left now, net of this device's unsynced spends; offline: true when spent from the allowance  case UseRefused(error: UseError.usedUp, :final left):    offerMoreExports(left); // KeyGrant's word: fewer left than asked (null when it gave no number)  case UseRefused(error: UseError.offlineLimit):    askToConnect(); // KeyGrant could not be reached, and this is past what this device may spend without it  case UseRefused(:final error):    showMeterProblem(error); // noMeter, notLicensed, revoked, device, upgrade, outdated, storage, invalidAmount}
final status = await license.status(); // offline, from the leasestatus.meters['exports']; // a MeterStatus: left 950, total 1000, offline 100, unsynced 0, resets false
  • offline is true on a UseSpent when the device spent from its offline allowance.
  • n is an int from 1 to 1,000,000,000, else invalidAmount.
  • How a spend works online and offline, and why a meter never takes the license away, is in How every SDK behaves.

When use does not spend

UseRefused.error is a UseError:

usedUp

The meter has fewer uses left than asked. left says how many are left, when KeyGrant gave a number. It is KeyGrant's own word, not a guess.

offlineLimit

KeyGrant could not be reached, and the spend is past this device's offline allowance for the meter or past what it last heard is left. left says what the device may still spend.

noMeter

The key has no meter with that code, or the code is empty or longer than 32 UTF-16 units.

notLicensed

No key's lease licenses this build on this device, so nothing was asked.

revoked, upgrade, outdated, device

KeyGrant's verdict on the key, this build or this device, as status() says it.

storage

The stored license could not be read just now. Try again shortly.

invalidAmount

n is not a whole number from 1 to 1,000,000,000. This is a bug in the app: nothing was asked.

LicenseStatus.meters

A map by meter code, on a licensed status from a key's lease that names meters, and empty on every other status. Each MeterStatus has:

left

What is left, net of this device's unsynced spends.

total

The uses the key holds, for good or for this billing period.

offline

This device's offline allowance.

unsynced

Uses this device spent that KeyGrant has not heard of yet.

resets

true for a meter that comes back each billing period.

How a device is identified

PlatformIdentifierSurvives
iOS and iPadOSAn install id the plugin keeps in the Keychain, on this device only and never synced, made the first time from identifierForVendor.The app being deleted and installed again.
AndroidANDROID_ID.A reinstall. It changes with a factory reset.
Windows, macOS and LinuxThe machine id, as every KeyGrant SDK reads it, so each reads the same device. On macOS the plugin reads it through IOKit, which a sandboxed Mac App Store build allows.
  • A locked iPhone: when the Keychain cannot be read (a device locked since it started), the SDK has no fingerprint that time and judges no lease by its device, so the customer keeps running.
  • An Android device without ANDROID_ID gets an install id of the SDK's own, and its seats are never held to it.
  • No host name: a phone has no host name to fall back to, so on iOS and Android there is none.
  • Trials: a trial's start is also kept in the iOS Keychain, so deleting the app does not restart a trial. Android keeps nothing past the app, so there a reinstall can.

A backup restored onto a new phone (Android's Auto Backup, an iOS backup) brings the stored license with it, held to the old device: it reads invalid with reason device until the customer activates again, which restoreByEmail does in an App Store build. To skip that, leave the KeyGrant folder out of your app's backup.

Storage

The license is one JSON file per product: on Windows, macOS and Linux in the folders every KeyGrant SDK uses, and on iOS and Android in the app's support folder, KeyGrant/<product>.json (Where the license is stored). Give your own storage adapter to keep it elsewhere.