Superizer

host API reference

cx.m42.superizer:host:0.1.0 — the service implementations, the platform actuals, activation and push routing. Only a host depends on this module. An app that imports anything from cx.m42.superizer.host has broken the boundary, and checkDependencyRules says so.


1. Building a host

public fun Superizer.Companion.build(
    scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main),
    block: SuperizerBuilder.() -> Unit,
): Superizer

scope is the host’s own: every app scope and instance scope is a child of it, so shutting the host down takes all of it with it. A test passes its own (Dispatchers.Unconfined, or a TestScope), which is also how the handler avoids reaching for a main dispatcher a unit test does not have.

Building registers every app and enables them all (handler.enableAll() on the scope), wires process lifecycle, attaches the push router and the device registrar, and starts collecting the event ring buffer.

SuperizerBuilder

Call Default What it does
host(info: HostInfo) required identity, platform, contract version, debug flag. The builder copies the registered service keys into info.services.
register(app: SuperizerApp<*>) — explicit registration. The order is the order All Apps and the Service Menu list apps in.
home(ids: List<AppId>) / home(vararg ids: String) empty what a fresh install shows on Home. Read on the first run only; after that Home is the user’s.
scheme(value: String) "superizer" the URL scheme: <scheme>://app/<id>/<path> and <scheme>://activate?…
languages(vararg LanguageOption, fallback: String = "en") ru, en what the Settings picker offers
promoCodes(table: Map<String, ActivationResult>) reject everything a shipped code table, normalised (case, spaces and punctuation ignored)
promoCodes(resolver: PromoCodeResolver)   the same thing against a server: fun interface { suspend fun resolve(code: String): ActivationResult }
serviceCode(code: String?) null the one door into the Service Menu in a release build. null leaves it debug-only (seven taps on the build stamp).
service(key: ServiceKey<T>, implementation: T) none registers an optional service. Apps reach it with runtime.service(key) / require(key).
clock(value: Clock) SystemClock inject time
network(value: NetworkService) Ktor substitute the whole service, so no test reaches the network
httpClient(value: HttpClient) KtorNetworkService.defaultClient() a client of your own, with the same service
auth(value: AuthService) AnonymousAuth  
notifier(value: Notifier) none draws a notification for a non-silent data message: fun interface { fun show(title: String?, body: String?, link: String) }
lifecycle(value: StateFlow<HostLifecycle>) PlatformLifecycle.state drive foreground/background by hand in a test
contexts(port: ContextPort) ContextPort.Single what the host is pointed at — servers, accounts — and the switcher at the top of the drawer. See below.
qrScanner(scan: suspend () -> String?) none the camera behind Activate’s scan button. Since 0.8.2 apps get it too, as QrScanner.
appFolders(root: String?) the platform’s where each app’s AppFolder goes: <root>/<app id>. Unset: <documents>/<host name> on desktop, the host’s Documents on iOS, its external files dir on Android, none on the web. null offers no folders. See below.

A complete example is in Getting started §3.4.

What a host offers apps without being asked (0.8.2)

Three optional services come with the builder, each unless the host registered its own under the same key:

Service When What it is
AppFolder wherever the platform has files and appFolders(null) was not called a folder per app, <root>/<app id>, that the person sees too
FileDownloader when the host has its own HTTP client, i.e. network(…) was not substituted KtorFileDownloader on that client: bytes, a size limit, two minutes
QrScanner when qrScanner { … } was set the same camera as Activate

On iOS the folder shows in Files › On My iPhone › the host only when the host’s Info.plist says so:

<key>UIFileSharingEnabled</key><true/>
<key>LSSupportsOpeningDocumentsInPlace</key><true/>

Without the two keys the folder still works — the app reads and writes it — but the person cannot reach it.

public typealias HostSession = AppSession   // re-exported, so a host file needs one import fewer

Contexts

A context is what the whole host is pointed at: a server, an account, a workspace. The library draws the switcher — a row at the top of the drawer with the current one, a sheet from the bottom with the rest, as ChatGPT and GitHub switch accounts — and hands the host the id that was picked. What switching means is the host’s.

public data class HostContext(val id: String, val title: String, val subtitle: String? = null)

public interface ContextPort {
    public val contexts: StateFlow<List<HostContext>>   // the person's order
    public val current: StateFlow<HostContext?>
    public fun switchTo(id: String)                     // the drawer is already closing
    public fun previous(): HostContext? = null          // where a long press on the row goes
    public fun title(langTag: String): String           // the sheet's heading: "Servers"
    public val addScreen: HostScreen? get() = null      // the sheet's "+" row
    public val manageScreen: HostScreen? get() = null   // the sheet's last row
    public companion object { public val Single: ContextPort }
}

ContextPort.Single — the default — is one context, the whole app: no row, no sheet, the drawer titled “Menu” and Home titled with HostInfo.name, exactly as before contexts existed. The switcher appears once there is more than one context, or one and an addScreen; Home is then titled with the current context and its subtitle.

The pattern RestoApp uses: one Superizer per context, each built on that context’s own storage and with its own apps, and a root composable that draws key(active) { SuperizerShell(active) }. Before letting go of one, drive its lifecycle(...) to Background so the open app writes its snapshot there — and comes back when the context does.


2. What you get without writing it

Screen / behaviour Where it comes from
Home — the chosen tiles, “+” to the catalog, long press to remove ui, driven by Superizer.home
All Apps — the catalog; a tap adds to Home or takes off ui, driven by registry.visible(unlocked)
The drawer — Home, every app on Home, All Apps, plus the footer AppScaffold
The context switcher — a row atop the drawer and a sheet, when the host has contexts SuperizerShell + ContextPort
Settings — language, haptics, a block per app, about, the 7-tap door SettingsScreen + handler.settingsSections
Activate — promo code and QR text ActivateScreen + ActivationPort
The Service Menu ServiceMenuScreen + DiagnosticsPort
The app container, the error screen, the veto dialog SuperizerShell
Deep links, push routing, session restore host

3. Services (cx.m42.superizer.host)

These are the implementations behind AppRuntime. A host normally never names them; they are public so a different host can reuse one.

Type Role
DefaultHostRuntimeFactory builds both runtime levels; scoped storage/logger/analytics/push per app id, shared locale/clock/events
PrefsStorageService(appId) StorageService over SafePrefs, prefixed app.<id>.; erase(appId) for reset
KtorNetworkService(client, online) NetworkService; defaultClient() sets a 15 s timeout and expectSuccess = false; TIMEOUT_MS = 15_000
ConsoleLogger(tag, buffer, verbose) ring buffer always, console only for warn/error unless verbose
LogBuffer(capacity = 500) lines: StateFlow<List<String>>, add, clear
LoggingAnalytics(appId, logger) prefixes the app id before anything leaves
AppLocaleService(supported, fallback) LocaleService and HostSettingsPort; resolves stored choice → device → fallback; useForTesting(tag), refreshFromDevice()
PlatformHaptics(enabled) a no-op when the Settings switch is off
AnonymousAuth always anonymous, honestly
RegistryView(registry, handler, unlocked) the read-only AppsService
ServiceRegistry(services) the optional-service map behind runtime.service(key)
SystemClock expect object — epoch millis per platform

Stores (cx.m42.superizer.host.storage)

Type Keeps
PrefsStorage the expect object every platform implements: get/put/remove/keys. Desktop: a properties file under ~/.superizer (useDirectory(File) to move it). Android: MODE_PRIVATE shared prefs (initPrefs(context) first). Web: localStorage.
SafePrefs the same, with every call guarded — a browser with site data blocked is “nothing was saved”, not a crash. Use this, not PrefsStorage.
HostKeys host.language, host.haptics, host.unlocked, host.home, host.session, host.push.topics — a namespace no app prefix can reach
UnlockStore which hidden apps this device has unlocked; unlock, lock, unlocked: StateFlow
HomeStore(defaults) the user’s ordered Home set; absent = first run → defaults, present-but-empty = a choice to keep
PrefsSnapshotStore the one SessionSnapshot; a snapshot an older build wrote and this one cannot parse means the same as no snapshot

Activation (cx.m42.superizer.host.activation)

Type Role
QrPayloadParser(registry, scheme, json) parse(text) for both forms: a JSON payload and a <scheme>://activate?a=…&c=… URL. Never throws; every branch is a typed rejection. SCHEMA_VERSION = 1, TYPE = "app_activation"
PromoCodeResolver fun interface { suspend fun resolve(code: String): ActivationResult }
LocalPromoCodes(table) a shipped table; normalise(code) strips everything but letters and digits and upper-cases, so test 2026, TEST-2026 and test2026 are one code
ActivationService the ActivationPort: the one place an activation turns into a running app

Push (cx.m42.superizer.host.push)

Type Role
PushTransport the platform seam: token, incoming, available, deliverTap, deliverMessage, requestPermission, subscribeTopic, unsubscribeTopic. A no-op actual on all three targets today.
NoopPushTransport the shared no-op body the three actuals delegate to
PushRouter payload → either data or a screen; enforces schema, app id, unlock, enabled, and link prefix
PendingRoute the consume-once RoutePort
TopicStore per-app topics, persisted; qualified(appId, topic) = "<id>.<topic>"
RoutedPush the PushService an app sees; refuses an undeclared topic
Notifier fun interface { fun show(title: String?, body: String?, link: String) }
DeviceRegistrar token × session × app ids → the server. There is no server, so it logs.
PushRegistration(token, platform), IncomingPush(data, tapped) wire types

Adding Firebase is one actual file plus a google-services.json — the router, the topic store, the Service Menu’s simulator and the bench app’s Push card all work against the seam today.

Platform (cx.m42.superizer.host.platform)

public expect fun currentPlatform(): Platform
public expect fun deviceLanguageTag(): String        // "" means "could not tell"
public expect fun openExternalUrl(url: String): Boolean
public expect object PlatformLifecycle { public val state: StateFlow<HostLifecycle> }
public expect object Connectivity { public val online: StateFlow<Boolean> }

Android additionally exposes AndroidHost.init(context) (vibrator, process lifecycle, connectivity). On Android the lifecycle follows ON_START/ON_STOP, never ON_RESUME/ON_PAUSE: a permission dialog pauses the activity, and a snapshot written on every one of those is a snapshot written on every tap.


4. What each platform needs at startup

Target Before the first composition
Android initPrefs(context) and AndroidHost.init(context); build the host once per process, not per activity; singleTask plus a VIEW intent filter for your scheme; hand intent.data to superizer.route.deliver(...) from both onCreate and onNewIntent
Desktop nothing required. Optionally PrefsStorage.useDirectory(File(...)); parse --link=/--activate= into route.deliver(...)
Web (wasmJs) nothing required. Parse ?link= and ?activate= (percent-decoded) into route.deliver(...); index.html needs a #composeTarget div

5. The Service Menu

Two doors, and only two:

  1. debug builds — seven taps on the build stamp in Settings (HostInfo.debug = true);
  2. any build — typing the serviceCode(...) on the Activate screen. It resolves to ActivationResult.HostCommand(OpenServiceMenu), which is matched by the shell — not a reserved app id, because one magic name always becomes several.

What it does, all through DiagnosticsPort: list every manifest with its outcome (including the rejected ones and why), run an app, run it with a pasted config, unlock/lock a hidden app, enable/disable, reset an app (storage + topics + snapshot + unlock, confirmed), show the last hundred events, show the log ring buffer, and simulate a push payload end to end.


6. Test hooks a host can use

Superizer.build(scope) {
    host(HostInfo(..., debug = true))
    clock(FakeClock())                      // cx.m42.superizer.testing
    network(FakeNetwork().apply { respond("https://api…", """{"rates":{}}""") })
    lifecycle(MutableStateFlow(HostLifecycle.Foreground))
    register(MyApp())
}

Plus PrefsStorage.useDirectory(tmp) on desktop so a test never touches the developer’s own file, and AppLocaleService.useForTesting(tag) to pin a language.