Skip to main content

FontResolver

class FontResolver(
var network: NetworkInterface? = null,
var fileStore: FontFileStoreInterface? = null,
val baseUrl: String = "",
val defaultMediaAssetPath: String = ""
) : CoroutineScope

Resolves a playout's font settings into something the platform can render with (#20287 / #19065).

A role is stored on the playout as an Id plus a name - skin_fontHeadingId + skin_fontHeading for the heading, skin_fontBodyId + skin_fontBody for the body - and [FontSpec.from] classifies that pair. A [FontSpec.Clip] goes to /json/mediaclip/<id> for a family name and the first ttf/otf asset, which is then downloaded, validated and cached; a [FontSpec.Named] resolves to [FontResolution.System] with no i/o at all; [FontSpec.None] is [FontResolution.Unusable].

A [FontSpec.Named] font touches neither cache nor network - the platform asks the OS for it by name and falls back to the built-in Lato when the device does not have it - so only a [FontSpec.Clip] side can time out.

One clip id is one file: a font mediaclip holds a single uploaded font, converted to several web formats, so there is nothing to match on - the first asset a native platform can register wins.

The two roles resolve independently and neither covers for the other: a role that is cleared or unusable renders in the built-in Lato while the other role keeps its font.

The parts that need no i/o are pure functions on the companion object; of those the platforms need only [cacheKey] and [clipCacheKey], to manage the cache directory themselves.

One resolver serves exactly one publication. [baseUrl] and [defaultMediaAssetPath] are fixed at construction, so every url, cache key and memo entry belongs to that one publication by construction - which is what lets the in-memory maps key on the bare clip id. Point a live resolver at a second publication and one publication's clip id answers for another's font. The SDKs build one once the embed's urls are known; when those urls change they [__destruct] it and build a new one.

The same invariant is what makes [__destruct] safe while a platform writer is still running: it drops the resolver's downloads slots, so its successor never sees them, and a different publication produces different cache keys - [cacheKey] hashes the absolute url the file comes from - so the loose writer cannot collide with any key the successor asks for.

Properties​

callbackDispatcher​

var callbackDispatcher: CoroutineDispatcher

Dispatcher the [resolveAsync] / [resolveBoth] callbacks are delivered on. Defaults to the main thread, which is where a font has to be registered on both platforms. A host without a main run loop - a unit test, a headless embedding - must set its own before the first call.

transient​

val transient: Boolean get()

The bytes never fully arrived. Nothing was learned about the font; retrying may.

transient​

val transient: Boolean get()

These bytes are not a font, and no number of retries will make them one.

NATIVE_FORMATS​

val NATIVE_FORMATS

How long the SDKs wait before drawing with Lato. Tunable per call.

NATIVE_FORMATS​

val NATIVE_FORMATS

Safety net on one font file download: how long the shared resolution waits for a platform store that has not called back. It is there for a store that breaks the "exactly one callback" contract, not for a slow connection, and it is far longer than [DEFAULT_TIMEOUT_MS] because nothing waits on it but the resolver - the store cannot be called off and the SDK is already drawing in Lato. See [download].

A platform that schedules its own retry must derive its window from this constant - read it, do not copy the number. A retry that fires while the first download is still running learns nothing the running one would not have told it, and a hard-coded copy next to this becomes wrong the moment this changes, silently and on one platform only.

NATIVE_FORMATS​

val NATIVE_FORMATS

How long one clip json request may take before it counts as a failed attempt. Two fit inside a resolution ([fetchClipJson] retries once) and both together stay far shorter than [DOWNLOAD_TIMEOUT_MS].

NATIVE_FORMATS​

val NATIVE_FORMATS

Formats the native platforms can register. woff/woff2/eot/svg/swf are ignored.

Functions​

resolveCached​

fun resolveCached(spec: FontSpec) : FontResolution?

The resolution for [spec] if it can be answered without any i/o beyond a synchronous cache read, else null. Null is not a verdict - call [resolveAsync] for that.

Both [FontSpec.None] and an uncached [FontSpec.Clip] return null and the return value does not say which: the caller tells them apart by the spec it passed in. A cleared role is a verdict [resolve] produces ([FontResolution.Unusable]), not a cache miss, and answering it here would make it indistinguishable from a cold clip.

resolve​

fun resolve(spec: FontSpec) : FontResolution

Full resolution of one spec. Returns a [FontResolution] for every outcome; the caller decides between "use it" and "fall back to Lato".

resolveAsync​

fun resolveAsync(
spec: FontSpec,
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
onResult: (resolution: FontResolution) -> Unit
)

Fire-and-forget resolution of one spec. Calls [onResult] immediately, on the calling thread, when [resolveCached] can answer; otherwise on [callbackDispatcher] after resolution or after [timeoutMs], whichever comes first. A timeout does not cancel the download.

[onResult] can arrive after [__destruct], carrying [REASON_DESTROYED]: teardown is what produces that verdict, so its delivery outlives teardown by design. The callback has to survive its owner being gone - both SDKs check the view is still attached before they register a font.

resolveAsync​

fun resolveAsync(spec: FontSpec, onResult: (resolution: FontResolution) -> Unit)

[resolveAsync] with [DEFAULT_TIMEOUT_MS]. Exists because Kotlin default arguments are invisible to Objective-C, so Swift cannot omit timeoutMs on the call above.

resolveBoth​

fun resolveBoth(
heading: FontSpec,
body: FontSpec,
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
onResult: (heading: FontResolution, body: FontResolution) -> Unit
)

Both roles at once: [heading] and [body] resolve concurrently and are reported in a single [onResult] call, exactly once - synchronously on the calling thread when [resolveCached] can answer both sides, otherwise on [callbackDispatcher].

The two sides are independent. Each gets its own [timeoutMs] window and both windows open at the same moment, so a slow or dead heading font falls back to Lato while the body font still arrives as [FontResolution.Loaded].

The same clip id in both roles is fetched once: the two sides share one resolution instead of racing each other into the memo.

[onResult] can arrive after [__destruct], carrying [REASON_DESTROYED] for whichever side the cancelled scope still owed an answer - see [resolveAsync]. This is the entry point both SDKs use, so the guard belongs to the caller: check the view is still attached before registering a font.

resolveBoth​

fun resolveBoth(
heading: FontSpec,
body: FontSpec,
onResult: (heading: FontResolution, body: FontResolution) -> Unit
)

[resolveBoth] with [DEFAULT_TIMEOUT_MS]. Exists because Kotlin default arguments are invisible to Objective-C, so Swift cannot omit timeoutMs on the call above.

cacheKey​

fun cacheKey(face: FontFace) : String

Cache key for one face: asset id plus the absolute url it is downloaded from, so a re-pointed src is a different key and re-downloads instead of serving a stale file, and two publications sharing an asset id and a relative path on different CDNs do not share one file. Two faces that really are the same url are the same bytes and do share it.

clipCacheKey​

fun clipCacheKey(fontId: String, baseUrl: String) : String

Cache key for one font clip's json, namespaced by [baseUrl]'s origin: clip id 2495 is a different font on every publication and two resolvers can share one cache directory. Falls back to the same noid placeholder as [cacheKey] when the id is blank, so the key is never bbfont-clip-<scope>-.json, which every blank id would collide on.