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,metaandpath_provider.
Add it
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
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
KeyGrantLicenseper app and share it. Its calls run one at a time, excepthas,require,upgradeUrl,pickUpandconfirmPickUp, which only read and run beside any call. - Results are sealed classes, so a
switchcovers every case. Every error is an enum whosewireis the server's word, as How every SDK behaves writes it (LicenseError.invalidKey.wireisinvalid-key).
LicenseConfig
Required. The product's API ID.
Required. Set it to https://api.keygrant.dev.
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.
One public key (a JWK): a key set of one. Give this or publicJwks.
The running app's major version. A 0.x build sets 0 and runs as major 1, and so does anything below it.
What the license's device list calls this device, cut to 120 UTF-16 units.
false on pooled, non-persistent machines: no lease is then held to its device. See Device management.
A Duration: how long any one request may take. The SDK holds every request to it itself.
The ids of the license files this build refuses, matched exactly (License files).
A LicenseAdapters(, any of them null for the default.
Calls
Returns a LicenseStatus (below), offline first, and never throws.
status() forced online: call it when the customer says they have paid. Never throws.
Starts this device's trial and returns its LicenseStatus. Never throws.
Returns a DeactivateResult with released (Deactivation).
Whether the license grants the entitlement code, offline, as status() judges it with no ask and no write. See Check an entitlement.
Like has(code), but throws MissingEntitlement, which names code, when the license does not grant it.
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.
Drops the stored offline license file. A key the device holds keeps working.
Returns UseSpent(left, offline) or UseRefused(error, left), and never throws (Metered uses).
Emails a six-digit code to the address, for key pickup. Returns PickUpCodeSent or PickUpSendFailed(error, retryAfter).
Returns PickUpKeysFound(keys), the address's keys of the product, or PickUpKeysFailed(error, retryAfter).
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.
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
ArgumentErrorfor a link that is not an absolute URL.
LicenseStatus
A LicenseState and, on expired, invalid and unknown, a LicenseReason (below).
The license key; on a status from a license file, the key the file records.
On a trial: the days left, and when it ends, in Unix milliseconds.
true for a test license.
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.
A map of MeterStatus by meter code, on licensed from a key's lease only; empty otherwise (below).
LicenseSource.file and who the file is licensed to, when the status rests on a stored offline license file.
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
invalidwith reasonupgrade, sell an upgrade (upgradeUrl). - On
unknownwith reasonstorage, 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:
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.
deactivate(), importLicenseFile() and removeLicenseFile(), behind a call that has not finished.
activate() and restoreByEmail(), when the stored license cannot be read or saved. deactivate(), importLicenseFile() and removeLicenseFile(), when it cannot be saved.
require(code), when the license does not grant code.
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.
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:
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.
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.
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.
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.
Three codes have been emailed for this key this hour. Use the last one, or wait.
Too many wrong emails or codes for this key this hour. Try again later.
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.
// 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 eachPickedKeyby itsmodelName, and activate the one chosen withactivate(picked.key, email: email, code: picked.activationCode). RestoreFailed.erroris aRestoreError: a pickup error below,noKey, or an activation error (upgradeandoutdatedwhen 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.
licenseFileExtensionis.kglicense,licenseFileMediaTypeisapplication/vnd.keygrant.license, andmaxLicenseFileBytesis 16,384.- The calls take the file's content, never its path:
importLicenseFile(text)orimportLicenseFileBytes(bytes). - List the ids of leaked files in
revokedLicenseFiles(Blocking a leaked file).
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), wherekindisLicenseFileKind.keyorLicenseFileKind.offline;LicenseFileRefused(error), aLicenseFileErrorbelow, with nothing stored;LicenseFileActivationFailed(error), for a key file whose key did not activate: whatactivate()answers.
Not a license file's text, or its token or claims are broken, or it is more than maxLicenseFileBytes.
A signed token, but not a license file (a lease, say).
Not signed by the key it names.
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.
Another product's file.
The file's id is listed in revokedLicenseFiles.
An offline file that does not cover this build: this build is newer than the file covers.
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.
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 offlineis true on aUseSpentwhen the device spent from its offline allowance.nis anintfrom 1 to 1,000,000,000, elseinvalidAmount.- 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:
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.
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.
The key has no meter with that code, or the code is empty or longer than 32 UTF-16 units.
No key's lease licenses this build on this device, so nothing was asked.
KeyGrant's verdict on the key, this build or this device, as status() says it.
The stored license could not be read just now. Try again shortly.
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:
What is left, net of this device's unsynced spends.
The uses the key holds, for good or for this billing period.
This device's offline allowance.
Uses this device spent that KeyGrant has not heard of yet.
true for a meter that comes back each billing period.
How a device is identified
| Platform | Identifier | Survives |
|---|---|---|
| iOS and iPadOS | An 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. |
| Android | ANDROID_ID. | A reinstall. It changes with a factory reset. |
| Windows, macOS and Linux | The 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_IDgets 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.