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): one d identifier, one name, optional summary, icon, image, and platform f tags.

  • Release (kind 30063): i application identifier, version, channel c, d equal to <identifier>@<version>, and one or more e asset references.

  • Asset (kind 3063): i, version, MIME type m, SHA-256 x, HTTPS url, optional byte size, and platform f tags.

  • Android assets also require integer version_code and at least one apk_certificate_hash containing 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.