Language packs enable you to provide additional languages with your app without increasing the initial download size.
These files are uploaded to the Meta Horizon Store when you upload your app package.
For Link PC-VR devices
On Link PC-VR devices, providing language packs as downloadable assets will decrease initial download size. The Meta Horizon Link app for Link PC-VR will allow users to select which language pack they want to use from the Details page.
The following image shows an example of how this looks.
For Meta Quest devices
On Quest, providing language packs as downloadable assets will decrease initial download size.
Developers will have to implement their own language picker in an app. Then, users will be able to select a language to use within that application, which will download the appropriate language pack.
For Meta Quest to correctly recognize your language pack you should name it with a language code per BCP47 format, with a suffix of “lang”. For example, en-us.lang and de.lang would be valid language pack names.
Upload a binary with language packs to the Meta Horizon Store
For language packs, use the --language_packs_dir parameter to specify the directory that contains the language packs.
When you upload new apps that have accompanying asset files, make sure the asset files have the same name as previously uploaded versions of the same file.
For Link PC-VR devices
Here is a sample command to upload a Rift package with a language pack:
In the top-right corner of the Meta Horizon Dashboard, select your org.
Select your app.
In the left-side navigation, select Distribution > Builds. A list of all builds for your app appears.
In the Build column, click on a build.
Select the Expansion Files tab.
Find the Expansion Files column for the build you selected and then select View Expansion Files. The different kinds of assets will display.
Look for Language Packs.
The following image shows an example of language packs.
Check for language packs in your app
Use the following steps to check for language packs in your app code.
Use the AssetFile.getList() function to get a list of all assets.
Check for an asset type via the AssetDetails.assetType property. A language pack has the asset type language_pack.
Read the language of the asset from the nullable AssetDetails.language property, whose tag field holds the BCP47 language code.
Download and apply a language pack at runtime
The runtime flow uses two language pack calls, get-current and set-current. Listing packs, tracking download progress, and confirming an install come from the asset file API rather than from a language pack API.
Work through the sections in order. Each one hands state to the next: get-current returns the tag that is applied now, progress observation has to be running before you apply a pack, set-current returns the asset ID that the status call takes, and the status response returns the filepath you load from.
Before your first request
Each request is a suspend function, so call it from a CoroutineScope that your code owns. The samples below use a scope named scope. Your code creates it, holds it, and cancels it, and cancelling it also ends the progress collection started in Track download progress.
Get the current language pack
The get-current call returns the asset details of the pack that is applied now. Read the BCP47 language tag from the language info, and read the on-disk location of the pack from the filepath.
Instantiate LanguagePack() and call getCurrent() from a coroutine. The call throws LanguagePackException on failure. AssetDetails.language is nullable, so use a safe call to read the tag.
import horizon.platform.languagepack.LanguagePack
// Call from a coroutine.
val details = LanguagePack().getCurrent()
val tag = details.language?.tag
val path = details.filepath
Track download progress
The asset file API reports download progress. Set up progress observation before you apply a pack, because an update that arrives before you are listening is lost.
Each update carries the bytes transferred and the total bytes. The bytes transferred value is -1 before the download starts, so check for a negative transferred count and a zero total before you compute a percentage.
AssetFile.downloadUpdate() returns a Flow backed by a shared session stream with no replay. Start collecting it before you call setCurrent(). A collector that subscribes later does not receive earlier updates.
Collecting first narrows the window for a missed update, and it does not close it. The underlying session is established asynchronously and exposes no readiness signal, so treat progress as display only and treat the status requests in Confirm the install as the authority on whether the pack is ready.
Keep the collection Job in the scope your code owns. Cancel that job, or cancel the whole scope, when you stop showing progress.
bytesTransferred is a Long and bytesTotal is a ULong, so compare against 0 and 0uL respectively.
import horizon.platform.assetfile.AssetFile
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
// scope is the CoroutineScope your code owns. Start this before setCurrent().
val progressJob: Job = scope.launch {
AssetFile().downloadUpdate().collect { update ->
if (update.bytesTransferred < 0 || update.bytesTotal == 0uL) return@collect
val progress = update.bytesTransferred.toFloat() / update.bytesTotal.toFloat()
}
}
// Retain progressJob and cancel it after installation or when leaving the picker.
Read assetId on the update. assetFileId is deprecated.
Apply or switch a language pack
Pass the BCP47 tag of the pack to the set-current call. The call downloads the pack automatically when the pack is not installed on the device.
The tag carries no .lang suffix. A pack file named de.lang has the tag de.
The result of the call carries the asset ID of the pack. Store that asset ID in state your code owns, such as a field on the object that drives your picker, because the confirm step needs it after the callback returns. The result also carries a filepath. Do not read content from it here: the pack is ready only once the status call reports installed.
setCurrent() throws LanguagePackException on failure. Compare the code property of the exception with the values of the LanguagePackStatusCode enum to tell the failures apart.
import horizon.platform.languagepack.LanguagePack
import horizon.platform.languagepack.LanguagePackException
import horizon.platform.languagepack.enums.LanguagePackStatusCode
// languagePackAssetId is a property on the object that owns scope.
scope.launch {
try {
val result = LanguagePack().setCurrent("de")
languagePackAssetId = result.assetId
} catch (e: LanguagePackException) {
if (e.code == LanguagePackStatusCode.LanguagePackNotFound.value) {
// No pack exists for that tag.
}
}
}
The enum defines NotEntitled (2001), LanguagePackNotSet (2002), LanguagePackNotFound (2003), InstallationFailedOrInternalError (2004), and StoreInstallationOrInvalidRequestError (2005).
Confirm the install
A progress update whose completed flag is true means the download finished. It does not mean the pack is installed. Request the asset status repeatedly until the download status changes from available to installed, and read the filepath after that.
Your code owns that polling loop. Build it with all four of these:
A delay between requests, so the loop does not spin.
A bound, either a maximum number of attempts or a timeout, so a pack that never reaches installed does not hold the loop open.
A cancellation path, so leaving the picker ends the loop.
Error handling on every response, so a failed request retries or ends the loop rather than reading as a status that is not installed.
Each snippet below is the body of one iteration of that loop, not the whole loop.
One iteration, using the asset ID you stored from set-current:
// Call from a coroutine. installedPath is a property your code owns.
val details = AssetFile().statusById(languagePackAssetId)
if (details.downloadStatus == "installed") {
installedPath = details.filepath
}
Load your localized content
Load your localized content from the filepath that the SDK returns. Never hardcode the path to a pack.
Take the filepath from the get-current response, or from a status response whose download status is installed. Do not load from the filepath on the set-current result, because at that point the pack is not confirmed installed.
Read the filepath from AssetDetails.filepath on the get-current or status result.
Populate language picker entries
The samples in this section populate the entries of a picker: they list the packs and build one label per entry. They do not run the switch. Combine them with the sections above for the whole flow.
These are the six steps end to end, and the state each one hands to the next:
List every asset file, as described in Check for language packs in your app. Keep the entries whose asset type is the string language_pack. The list call takes no filter parameter, so filter the results in your app code. This gives you the entry list.
Label each entry from the names on its language info: the English name, for a list that stays readable to a user who selected the wrong language, and the native name, for the spelling used by that language. This gives you the display text.
Call get-current and pre-select the entry whose tag matches the tag it returns. This gives you the starting selection.
Start progress observation, then call set-current with the tag of the entry the user selects. Store the asset ID from the result in your picker state. This gives you the asset ID.
Request the asset status in your bounded loop until the download status is installed. Store the filepath from that response. This gives you the filepath.
Reload your localized content from the stored filepath, then end the progress observation.
import horizon.platform.assetfile.AssetFile
// Call from a coroutine.
val packs = AssetFile().getList().filter { it.assetType == "language_pack" }
packs.forEach { pack ->
val info = pack.language ?: return@forEach
val label = "${info.englishName} (${info.nativeName})"
}