Software localization

iOS App Translation Over the Air with Phrase Strings

If you want to ensure all your app users get the right copy, Phrase's over-the-air feature can help you publish your translation updates instantly.
Software localization blog category featured image | Phrase

Our app team doesn't need to rely on us developers (and take up our precious time) when deploying new translations to our app. We can also sidestep App Store approval when updating the localized copy of our app. Let's see how we can realize these benefits in our iOS app with Phrase's over-the-air feature. We'll add Phrase Over the Air to a working app, wiring everything up on the Phrase console and our XCode project.

Our app

We're picking up where we left off in our previous article, iOS App Localization with Phrase. In that tutorial, we took an app and connected it to Phrase to streamline our localization workflow. We're assuming that you've read that article, and/or that you have Phrase connected to your app already. If not, we highly recommend checking out that article before you proceed.

Demo app | PhraseShort Circuit, our demo app, a curated list of electronic music

Our little demo app lists electronic music tracks along with their artists and release dates. It's a simple, two-screen app with a UITableViewController that lists all our tracks, and has a details screen for displaying a single track's info.

🔗 Resource » If you want to work along, you can grab the starter project from Github. The completed project is linked near the bottom of the article.

Prep

Alright, let's start getting our app ready for over the air translations. In our iOS app, we'll move our localized text from storyboards to a good old Localizable.strings. We'll then add over the air translation support to our Phrase project. To round out our prep, we'll install the Phrase iOS SDK, which syncs our app's translations with our Phrase project over the air.

Moving our app's translations from Storyboards to Localizable.strings

Phrase Over the Air translations will work just fine with storyboard .strings files. However, just to keep things simple here, let's put all our translatable strings in a single Localizable.strings file. It's a simple refactor from Main.strings to Localizable.strings, so let's get to it.

📖 Go Deeper » If you want to learn more about iOS storyboard localization, check out our guide, iOS i18n: Internationalizing Storyboards in XCode

Let's start with our screen's navigation item titles. We can simply override the titles set in our storyboards by setting the title field in our view controllers. Our home screen, which lists our artists, is connected to TrackListViewController. Let's update that controller now.

class TrackListViewController: UIViewController {

   // ...

    override func viewDidLoad() {

        super.viewDidLoad()

        title = NSLocalizedString("trackListTitle", comment: "")

        // ...

    }

    // ...

}

We use the usual NSLocalizedString to grab our translation from Localizable.strings. Let's make sure that the translation string exists in the English version of Localizable.strings.

"copyright" = "Copyright © %@ %@. All rights reserved";

"trackListTitle" = "Short Circuit";

With that in place, our app will look exactly the same when run in English. We'll add our non-English locales a bit later. Let's finish moving our translations out of our storyboards first.

For our labels, we have to ensure that we have outlets from our storyboards to their respective controllers.

Ensuring that outlets from storyboards are connected to respective controllers | PhraseAll of our storyboard labels get IBOutlets

After we connect our labels, we just repeat the same process of calling NSLocalizedString to provide translated strings to them. And, of course, we add our new strings to our English Localizable.strings.

Our TrackDetailsViewController, which helps display a single track's info, would look like this after we move our storyboard translations:

import UIKit

class TrackDetailsViewController: UIViewController {

    @IBOutlet weak var trackNameHeaderLabel: UILabel!

    @IBOutlet weak var artistNameHeaderLabel: UILabel!

    // ...

    var track: Track?

    override func viewDidLoad() {

        super.viewDidLoad()

        title = NSLocalizedString("trackDetailsTitle", comment: "")

        if let track = track {

            trackNameHeaderLabel.text =

                getLocalizedHeaderText(key: "trackNameHeader")

            artistNameHeaderLabel.text =

                getLocalizedHeaderText(key: "artistNameHeader")

            releaseDateHeaderLabel.text =

                getLocalizedHeaderText(key: "releaseDateHeader")

            // ...

        }

    }

    // ...

    fileprivate func getLocalizedHeaderText(key: String) -> String {

        return NSLocalizedString(key, comment: "")

            .localizedUppercase

    }

}

The getLocalizedHeaderText function is just a little helper that we use to provide localized uppercase formatting to our translated label text.

After we add our translation strings to our Localizable.strings, our app looks exactly the same as it did before.

App with successful refactor | PhraseLooks like a successful refactor

We can now completely remove our storyboard translation files from our XCode project. We do so by selecting the storyboard file in the Project navigator and unchecking each locale under the Localizations header in the File inspector.

Deleting unnecessary storyboards | PhraseBye, bye clutter

📖 Go Deeper » If you're working with iOS storyboards a lot you may want to check out our article, Automate iOS Storyboard Localization.

Updating our translations with Phrase

We now need to update our the Localizable.strings translations for our non-source (non-English in my case). We do this so that our non-English users can see our update labels in their language. We can, of course, use our usual Phrase workflow to translate our labels.

First, we'll upload our new source by doing a $ phrase push from the command line.

Our new translation keys should now show up on the Phrase web console. Once our translators have translated the keys into all the locales our app supports, we can $ phrase pull from the command line to get our updated Localizable.strings files. Now when we run our app in a non-source locale (Arabic in my case), we see that our views are translated as expected.

Translated demo app | PhraseAll well in all locales

📖 Go Deeper » You can learn more about the core Phrase translation workflow with iOS in iOS App Localization with Phrase.

Now that we've moved our storyboard translation strings to Localizable.strings files, we can move on to setting up over the air translations in our Phrase project.

Adding an over the air distribution in Phrase

Let's make our Phrase project ready for over the air translations. In our Phrase web console, let's open our organization dropdown in the top navigation bar and select Integrations.

Opening the Integrations menu in Phrase | Phrase OTA is under Integrations

This will open up our Phrase Integrations page. Here we can find the Over the air (OTA) row and click its Configure button.

Configuring OTA | PhraseThe growing number of Phrase integrations

We should now see the Over the air page inviting us to create a distribution. A distribution is a project OTA configuration that targets one or more platforms and has fallback options.

Creating a distribution for the demo app | PhraseWe can create an OTA distribution specific to our iOS app

Let's click the Create distribution button to open the Add distribution dialog.

App distribution options | PhraseThe Add distribution options are pretty self-explanatory

For our current project, we can give the distribution a name of our choosing, select the Phrase project associated with our iOS app, and check the ios platform checkbox. We can leave the default fallback options as they are since they're fine for our purposes. Let's click the Save button to create the distribution.

💡FYI » You can change your distribution settings at any time by returning to the Over the air page.

Adding our first OTA release

In order to work with the iOS SDK without seeing a failure result when we attempt to update our translations in the app, we need to have an OTA release. A release is basically a snapshot of our Phrase translations that we can test and deploy to our iOS app over the air. We'll get into releases later. For now, let's create a first release to get working with the SDK.

We'll start by clicking the Create release button at the bottom of our distribution page.

Create release button | Phrase

Release me

This opens the Add release dialog. We can add a description to help us identify the release later, leave everything else as it is, and click Save.

Add release menu | PhraseA release is a translation snapshot that we can deploy

We should now see an entry under Releases near the bottom of our distribution page.

Now that we have an OTA distribution and a first release we can set up OTA in our iOS app.

Installing the Phrase iOS SDK

We'll need the Phrase SDK installed to use OTA. We can do so through CocoaPods, Carthage, or manually. We'll go over the CocoaPods installation here, but you can see the learn ways to install the SDK by reading our guide, iOS SDK Installation Instructions.

To install the SDK via CocoaPods, we need to to have CocoaPods installed on our Mac. You can see the installation instructions on the CocoaPods website. Assuming that we have CocoaPods installed, we can navigate to our root project directory (the one that contains our .xcodeproj file) and run the following command from the terminal.

$ pod init

This will create a Podfile in our root directory. Let's open this file in our favorite code editor and add a line to it so that it looks like so:

target 'phraseapp-demo' do

  use_frameworks!

  # Pods for phraseapp-demo

  pod 'PhraseSDK'

end

With that edit saved, we can run the following from our terminal:

$ pod install

If all went well, we should have received output that indicates that Phrase has been installed. After that, we can close any XCode windows we have open and open the .xcworkspace file that CocoaPods added to our project root after installing Phrase.

Configuring Phrase in our iOS app

With the SDK installed, let's get it wired up to our app. First, let's take a quick look at two main methods that the Phrase SDK exposes.

Phrase.shared.setup(distributionID:environmentSecret:timeout:) initializes the shared Phrase singleton object, taking our credentials and an optional timeout parameter.

Phrase.shared.updateTranslations(translationResult:) manually updates the translations in the app, pulling in fresh ones from the Phrase servers if a new release exists. updateTranslations takes an optional escaping closure, translationResult, that we can provide if we want to take action when the update request completes.

📖 Go Deeper » You can find more details about the Phrase iOS SDK

in our guide, iOS SDK Installation Instructions.

Ok, let's add an OTATranslations class that provides a light wrapper around PhraseApp.

import PhraseSDK

class OTATranslations {

    static let shared = OTATranslations()

    private init() {

        #if DEBUG

        Phrase.shared.debugMode = true

        #endif

        let config: PList? = loadConfig()

        if let config = config {

            #if DEBUG

            let environmentTokenKey: String = "devToken"

            #else

            let environmentTokenKey: String = "prodToken"

            #endif

            Phrase.shared.setup(

                distributionID: config.getValue(withKey: "distributionID"),

                environmentSecret: config.getValue(withKey: environmentTokenKey),

                timeout: config.getValue(withKey: "timeout")

            )

        }

    }

    func updateTranslations(onUpdateComplete: (() -> Void)? = nil) {

        do {

            try Phrase.shared.updateTranslations { result in

                switch result {

                case .success(let updated):

                    if updated {

                        printIfDebug("Translations updated successfully")

                        onUpdateComplete?()

                    } else {

                        printIfDebug("No new translations found")

                    }

                case .failure:

                    printIfDebug("Failure updating translations")

                }

            }

        } catch {

            printIfDebug("Error updating translations: \(error)")

        }

    }

    fileprivate func loadConfig() -> PList? {

        do {

            return try PList(pListResource: "PhraseApp")

        } catch {

            printIfDebug(

                "Error loading PhraseApp configuration from plist, \(error)")

        }

        return nil

    }

}

💡FYI » Phrase.shared.debugMode is a flag that makes the Phrase SDK more verbose, outputting more details to the console, when turned on. We turn it on when we're running a debug build of our app.

💡FYI » The printIfDebug(_:) function is a custom helper that simply outputs its given string to the console if the #DEBUG flag is on. If you want you can view the function's code on this project's Github repo.

We provide a simple OTATranslations.shared singleton object to be used in our app. Our object is initialized by pulling Phrase config values from a PhraseApp.plist and providing them to PhraseApp.setup. To make this work we need to create the PhraseApp.plist class in our XCode project. Let's do so now. It should look a little like the following.

Keys | PhraseMake sure that your keys match the ones here

Here's a text version if you want to ⌘-C.

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">

<plist version="1.0">

<dict>

    <key>distributionID</key>

    <string>your_distribution_id</string>

    <key>devToken</key>

    <string>your_distribution_development_secret</string>

    <key>prodToken</key>

    <string>your_distribution_production_secret</string>

    <key>timeout</key>

    <integer>10</integer>

</dict>

</plist>

✋🏽 Head's Up » You might want to add your PhraseApp.plist file to your .gitignore to guard your secret keys.

💡FYI » The PList class we're using to load our .plist file values is a custom helper class. You can check out its code on this project's Github repo.

The values for our distributionID, devToken, and prodToken can be found on the OTA distribution page in the Phrase web console. We can navigate to our project's dashboard Overview tab, and click the View distributions button under the Over the air section. This opens the Over the air page, and from there we can find and click our project's OTA distribution in the Distributions list. Once on the specific distribution's page, we can find the Distribution ID, Development secret, and Production secret values to copy and paste into our .plist file. Of course, we're using "token" instead of "secret" in our .plist, but you probably figured that out already.

Copying ID and secret tokens | PhraseCopy & paste your ID and secret tokens

Ok, now that we have the Phrase SDK wired up to our OTATranslations class, let's make use of it in our AppDelegate.swift.

import UIKit

@UIApplicationMain

class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        OTATranslations.shared.updateTranslations()

        return true

    }

}

In our AppDelegate's application(_:didFinishLaunchingWithOptions:) method, which is run when our app is first started, we manually update our translations over the air. This pulls in any new translations in the background. Our current setup will not have the UI update immediately, however. The translation updates that are pulled during this app launch will appear to the user during her or his next app launch. This is often the behaviour we want: having new translation strings pop into view as the user is in the middle of doing something in the app could be a weird and potentially unsettling user experience.

Manually refreshing the UI after pulling in new translations

However, depending on our requirements, we may need to refresh the app UI immediately after pulling in a translation update. We can do that using the callback closure parameter we provided in updateTranslations(onUpdateComplete:).

import UIKit

@UIApplicationMain

class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        OTATranslations.shared.updateTranslations() {

            AppDelegate.reloadRootViewController()

        }

        return true

    }

    static func reloadRootViewController() {

        DispatchQueue.main.async {

            let storyboard = UIStoryboard.init(name: "Main", bundle: nil)

            let appDelegate = UIApplication.shared.delegate as! AppDelegate

            appDelegate.window?.rootViewController =

                storyboard.instantiateInitialViewController()

        }

Here we provide the onUpdateComplete argument to updateTranslations as an escaping closure. This closure will be called when there are new translations that have been pulled in successfully. When this happens, we call our custom AppDelegate.reloadViewController() method. This will effectively reload our app and show the user our most recent translations.

✋🏽 Head's Up » Be careful when reloading the app's root view controller. If the user has already started to do something in your app, he or she can have that flow interrupted—and their unsaved data lost—when the reload happens. To reduce the chances of this happening, you may want to use a relatively low timeout configuration when setting up the PhraseApp SDK. That way, if the translation pull request takes a while, it will just cancel out and not trigger the reload. Alternatively, and more robustly, you could have a blocking loading indicator appear during the request. This would disallow the user from interacting with your app altogether during the translation load, preventing the aforementioned data loss.

The freedom of iOS over the air translations with Phrase

We're basically done with integrating OTA translations into our app. After we publish this version of our app to the App Store, we're free to send translations to our users without going through App Store approval again. All we have to do is head to our Phrase web console, and

  1. Update our locale translations, then
  2. Create a new release for our OTA distribution (just as we did before), and
  3. Publish the release.

Publishing release | Phrase

Don't forget to publish your release after reviewing

It's really that simple. Once we publish our release in the Phrase web console, our users will start getting the updated translations. All users that have the version of the app wired up to the PhraseApp SDK will pull in the new translations over the air. No need to publish a new app version, no need to wait for App Store approval. Sweet liberty.

🔗 Resource » You can view and download the completed code for the iOS project we built here from our Github repo.

Further reading

If you want to dive deeper into iOS internationalization and localization, check out some of our other articles on the subject.

And that's a wrap

Wiring up our app to Phrase over the air translations is the work of a day or two. This can save us dozens upon dozens of hours as we publish new translations directly to our app users. With OTA we also have the added benefit of skipping App Store approval (just for a text update). And Phrase offers you much more than OTA: Phrase can really streamline the localization process for your app. Take a look at all of Phrase's products and sign up for a free 14-day trial.

I hope you're starting to see the potential of over the air translations, and that you enjoyed this little guide into making OTA work in your iOS app. We'll be adding more OTA content in the days to come, so stay tuned, and happy coding :)