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.