Software discovery and Android updates
NDK's software use case discovers NIP-82 application, release, and asset
events from trusted publishers. ndk_flutter adds a controller, reusable
widgets, and a verified Android APK installer.
Published event requirements
Choose one publisher public key and keep it stable. Applications trust only events authored by the configured publisher.
-
Application (
kind 32267): onedidentifier, onename, optionalsummary,icon,image, and platformftags. -
Release (
kind 30063):iapplication identifier,version, channelc,dequal to<identifier>@<version>, and one or moreeasset references. -
Asset (
kind 3063):i,version, MIME typem, SHA-256x, HTTPSurl, optional bytesize, and platformftags. -
Android assets also require integer
version_codeand at least oneapk_certificate_hashcontaining a SHA-256 signing-certificate digest.
Publish asset events before release events so each release reference resolves.
Use relay hints in release e tags when asset metadata events live on specific
relays. Relays store Nostr metadata events; Blossom servers store APK binaries.
For ABI-specific Android releases, publish a separate asset for each APK and
use one of android-arm64-v8a, android-armeabi-v7a, android-x86, or
android-x86_64 as its f tag. selectUpdate chooses a compatible asset with
a newer Android version code and matching certificate.
Core NDK API
const app = SoftwareAppRef(
publisher: '<trusted-publisher-hex-pubkey>',
identifier: 'com.example.app',
);
final metadata = await ndk.software.getApp(
app: app,
relays: const [
'wss://catalog.example',
'wss://catalog-backup.example',
],
);
final releases = await ndk.software.getReleases(
app: app,
channel: 'main',
relays: const [
'wss://catalog.example',
'wss://catalog-backup.example',
],
);
final subscription = ndk.software
.watchReleases(
app: app,
channel: 'main',
relays: const [
'wss://catalog.example',
'wss://catalog-backup.example',
],
)
.listen((releases) {
// Resolve assets or update application state.
});
// Call from the owner's asynchronous cleanup path to close the relay request.
Future<void> dispose() async {
await subscription.cancel();
}
Use resolveAssets(release) to fetch one release's referenced asset events.
When evaluating multiple releases, use resolveAssetsForReleases(releases);
it resolves every referenced event ID in one relay request and returns assets
keyed by release event ID. Use selectUpdate(...) when integrating a platform
installer directly.
Relay and Blossom configuration
Pass every catalog relay that should be queried through relays. These relays
are used to discover application and release events. NAppUpdateController
also uses them to resolve asset metadata, load release comments, reactions, and
zap receipts, and publish new comments.
An empty relay list does not mean “use Zapstore.” It delegates relay selection to the configured NDK engine and bootstrap/routing data. That may be useful in a general Nostr client, but an application updater should normally provide at least one explicit catalog relay so release discovery remains predictable.
A release can point at an asset event on another relay by including a relay
hint in its e tag:
["e", "<asset-event-id>", "wss://assets-relay.example"]
resolveAssets queries the union of configured relays and all relay hints from
the release. A hint locates the kind 3063 asset metadata event; it does not
identify the server hosting the APK.
Publisher selects the Blossom server. Asset event carries the final public HTTPS URL:
["url", "https://blossom.example/<sha256>"]
Installer downloads this signed event URL directly, so applications do not configure a Blossom base URL. Custom or self-hosted Blossom works without client changes when publisher uploads the APK there and publishes its HTTPS URL, size, hash, package metadata, ABI, and certificate digest in the asset event.
Each asset currently supports one url tag. Automatic retries cover the same
URL and its redirects, but there is no Blossom mirror/fallback list. Publishers
needing redundancy should use a resilient endpoint or CDN in that URL.
Flutter controller
Create one controller for the application, start it after NDK initialization, and dispose it with the owning widget or application service:
late final NAppUpdateController appUpdater;
void initializeUpdates(NdkFlutter ndkFlutter) {
appUpdater = NAppUpdateController.self(
ndkFlutter: ndkFlutter,
app: const SoftwareAppRef(
publisher: '<trusted-publisher-hex-pubkey>',
identifier: 'com.example.app',
),
currentVersion: packageVersion,
externalUpdateUrl: Uri.parse('https://github.com/relaystr/ndk/'),
channel: 'main',
relays: const [
'wss://catalog.example',
'wss://catalog-backup.example',
],
// Optional network budgets. Cached social data stays visible during refresh,
// including when an engagement query times out. Initial loads commit results
// only after both engagement queries complete.
queryTimeout: const Duration(seconds: 5),
engagementQueryTimeout: const Duration(seconds: 4),
engagementMetadataTimeout: const Duration(seconds: 3),
);
unawaited(appUpdater.start());
}
void disposeUpdates() {
appUpdater.dispose();
}
currentVersion enables release matching without Android package APIs.
On Android, APK installation remains enabled by default. On iOS, desktop, and
web, release discovery, changelog, release details, zaps, reactions, and
comments remain available, while APK installation is disabled. When a newer
published release exists, externalUpdateUrl adds an external download action;
set it to that application's download or release page. The sample app uses
https://github.com/relaystr/ndk/.
publisher, identifier, and channel must match published NIP-82 events.
Use production relay URLs and publisher keys from build configuration rather
than user input. Blossom server configuration belongs to publishing tooling,
not this controller.
Widgets
Register ndk_flutter localization delegates as described in the package
README, then pass the shared controller to any update widget:
NAppVersion(controller: appUpdater); // compact version and update icon
NAppUpdateBanner(controller: appUpdater); // actionable update banner
NAppUpdateTile(controller: appUpdater); // settings/list entry
NReleaseHistoryScreen(controller: appUpdater); // full changelog route
NAppUpdateBuilder(
controller: appUpdater,
builder: (context, state) => Text(state.status.name),
);
NAppVersion acquires a live Nostr release subscription by default. A newly
published matching release therefore updates controller state and shows the
icon without waiting for restart or manual refresh. The subscription is
released when the widget is disposed; multiple version widgets share one
controller subscription safely.
Disable live watching for a widget when polling or application-owned subscription lifecycle is preferred:
NAppVersion(
controller: appUpdater,
watchReleases: false,
);
controller.start() performs the initial check without keeping a live
subscription. Applications without NAppVersion can explicitly retain one:
await controller.start(watchReleases: true);
NAppVersion always displays the installed version. A theme-aware tertiary
icon appears when an update is available, including after the user selects
Later. Tapping the version opens NAppUpdateDialog, which reports either the
available update or that the installed version is current.
The dialog shows release date and a concise preview of the release event content. Its Changelog action opens a newest-first release history screen. Selecting a release opens the same details sheet used by Release details. The sheet starts with a horizontally scrollable version selector. Switching versions updates only release-specific date, notes, asset trust information, and technical details.
Publisher identity, zap/reaction/comment counts, and discussion appear below
an Across all releases divider. They are application-wide and remain stable
while versions change. Release history reuses state.releases from update
discovery, so opening it performs no extra relay query. Social state is also
shared instead of being refetched for every selected version.
Community data starts loading when its summary actions appear. Opening release
details reuses that request and state instead of fetching it again. Counts and
comments render before optional profile metadata; cached values remain visible
during refresh. Social query failures never block update discovery, download,
verification, or installation. When NDK has a logged-in signing account, the
details sheet also publishes NIP-22 kind 1111 comments rooted at the
addressable release event. Without a signer, discussion remains read-only.
Engagement uses two concurrent, single-filter relay queries: one #p query for
zaps and reactions, and one NIP-22 #A query for comments. This avoids
deprecated multi-filter requests while keeping social loading to one parallel
network phase. Release discovery and asset resolution complete independently;
social data never delays update availability.
Zap totals are derived from parseable kind 9735 receipts addressed to the
release publisher. Treat them as community signals rather than payment proofs:
full LNURL-provider receipt validation requires recipient-specific LNURL
context that this reusable widget does not own.
NAppVersion also remembers the installed Android version code. First launch
only establishes a baseline. After a later launch detects a higher installed
version code and resolves its release event, it automatically opens release
details once so the user can read notes and community activity for the version
they just installed. Closing the sheet acknowledges that version. This small
cross-launch marker uses shared_preferences; it does not hide available
updates or suppress the update icon.
Android configuration
Applications using AndroidPackageInstaller must opt into package installation
in android/app/src/main/AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application>
<!-- application components -->
</application>
</manifest>
This restricted permission is intentionally absent from the ndk_flutter
library manifest. Declare it only when self-updating is a core app feature and
your distribution channel permits it. Android 8 and newer also ask the user to
allow installs from the application before the first update.
The library supplies its non-exported FileProvider and cache path through
manifest merging. No storage permission is required for update APKs.
Installer validation
Before handing an APK to Android, AndroidPackageInstaller verifies:
-
HTTPS URL and a DNS check rejecting local addresses before the original request and every redirect;
-
declared byte size when present, plus a 512 MiB hard download ceiling;
-
SHA-256 file hash;
-
package identifier and Android version code;
-
NIP-82 certificate digest and compatibility with installed signing history;
-
ABI and minimum Android version during update selection.
The DNS check does not bind the connection to the checked addresses. It is not a guarantee against DNS rebinding between validation and connection.
Only one download may run at once. Cancellation closes the active connection, waits for its worker to stop, and removes the partial APK.
For release builds, publish split-per-ABI APKs and derive every asset tag from the final signed file. Never publish metadata calculated from a universal, unsigned, or differently signed APK.