Sunday, May 04, 2014

Tips for Hub Integration on BlackBerry 10

A central feature on the BlackBerry 10 platform is the "BlackBerry Hub." This is the unified inbox of the device, where users expect to see all their messages and notifications. Starting in OS 10.2, there is now a (limited access) public API for integrating with this feature. Apps that integrate with the hub are able to provide their own icon, category, and actions within it. Apps that don't are limited to posting fairly simple notification items. If you develop an app that is messaging-oriented, users are likely to want you to become hub integrated.


Introduction

The hub API, actually known as the "Unified Data Source" (UDS) API, is quite thoroughly documented here:
https://developer.blackberry.com/native/reference/core/com.qnx.doc.uds.lib_ref/topic/manual/overview.html

In order to use it, you will need to request additional permissions from BlackBerry. The above page has some details and links about how to do that.

Before going forward, I should mention that the hub isn't the most beginner-friendly or idiot-proof thing to integrate with. Even if it seems straight forward and well documented when you first get it working, the general "success path" is not what makes it hard. I'll even assume that its covered well enough by the documentation and sample code that I won't spend much time on it. Its all the paranoid error handling you need to do, that is rarely covered by examples, that you simply cannot skimp on. This is something that I will be discussing at length in this article.


That being said, I'd actually recommend against doing hub integration for your app unless:
  • You are developing a messaging-oriented application, and/or expect to have a non-trivial amount of notifications about content coming from your app
  • Your app contains a background service (a.k.a. "headless") component
  • You are comfortable developing Cards in addition to the normal UI mode for your app
  • You are familiar with the Invocation Framework
  • You have implemented a way to let users capture and send in logs and core files from production builds of your app
  • You are comfortable programming in C and/or calling C APIs from Qt/C++.
In other words, don't do it just because you think its one more cool thing. Make sure you know what you're getting yourself info, and are prepared to deal with the results.

Overview of the Hub

Before we get into the details, its worth explaining a bit about how the hub actually works. To begin with, the hub is actually an app ("sys.pim.messages") that's built into the system and always running. For many first-party functions (Email, SMS, etc.), it interfaces with the PIM Services. For other use cases, such as social and messaging apps, it provides a PPS server object ("/pps/services/pim/unified"). This server object is guarded by the "_sys_access_pim_unified" permission and accessed from application code using the UDS API.

Apps using the UDS API register themselves with the hub, setup an account within it, and provide a series of Inbox items that are displayed as list entries to the user.  They also can provide account and item-context actions that the user can see (e.g. compose, mark read, etc.).  Some actions may also be configured by default, such as opening or deleting an item.

What's important to note here, is that every action a user takes on a Hub account or Inbox item is actually implemented as an invoke request on the client application. Open actions are typically handled by launching a card, which is somewhat obvious.  What is less obvious is that most other actions (including mark read/unread and delete) are handled by invoke requests as well. Those invoke requests are typically handled by the client app's background service, which will then make a call to update the relevant item in the hub.

You can think of the Hub itself as nothing but a giant invoke request initiator and card host. If there isn't an app capable of handling those invoke requests, then the hub won't let you do very much.


General Guidelines

There are some general guidelines you should keep in mind when working with the hub, via the UDS API:
  • Problems are unlikely to surface on your development device, but will start to happen frequently once your app gets into the hands of many real users.
  • Every API call can fail,  and you must be prepared to gracefully handle these errors and recover from them. If you mess this up, bad things may happen.
  • If the device has booted recently, errors are a lot more common. Everything else in the system is also trying to talk to the hub, increasing the change of timeouts.
  • You can never trust your app's preexisting knowledge of its hub configuration. The hub is not part of your app, and thus the lifetime of its configuration data is not necessarily governed by the same rules as your app's configuration data. This is especially true if a user does a backup/restore, a device switch, or installs an OS update.
  • The hub has no query API. I'm serious. There is no way to ask the hub what bits of your app's configuration it already knows about. You have to code defensively, assuming a completely unknown state on startup.

The UDS Handle

The central point for interfacing with the UDS API is through the handle (opaque pointer of type uds_context_t). Before using the handle, you must make sure its correctly initialized. If it isn't, then you might find yourself sending bad data to the hub.

The most straightforward way to do this is a pattern that should be followed before and after every UDS API call (obviously excluding the ones explicitly mentioned below):
  • Before the call, if the handle is null:
    • uds_init()
      • If successful, proceed with next step
      • If failed, abort and fail the operation
    • uds_register_client()
      • If successful, proceed with the API call
      • If failed, call uds_close() on the handle and then set it to null
  • Make the API call, and check the return value
    • If the result is UDS_ERROR_TIMEOUT
      • You can retry the API call
    • If the result is UDS_ERROR_DISCONNECTED
      • Call uds_close() on the handle and then set it to null
      • Loop around, reinitialize the handle as described above, then you can retry the API call
    • If the result is anything else, assume the API call failed and shouldn't be retried.
This pattern can be used as part of a retry loop, and probably should. It is very common to see timeout errors, and disconnect errors frequently follow them.

Create/Update Operations

As mentioned in the start of this section, the hub has no query API. You have no certain way to know if your account has been created, your actions are available, or the inbox item you want to update has been added. The safest way to deal with this, is to implement a pattern that will either add or update depending on the result of the corresponding operation. While specifics may vary depending on the operation, the typical pattern is to try updating first, and if that fails (for a reason other than timeout or disconnect), try adding instead.

Initialization

Registration

After creating your handle with uds_init(), the next step is to actually register yourself with the hub. This is done using the uds_register_client() call. What's important to note here is that while a valid registration is a prerequisite for everything else, it isn't actually visible to the user in any way at all.

This function has two parameters that I feel need some elaboration: pServiceURL and pAssetPath.

pServiceURL - This should be set to a string that is unique to your application. Do not just set it to the string in the examples.

pAssetPath - This should be set to the fully-qualified path of your installed application's public assets. This is slightly different from where its private assets are, and is typically where you'd also find the app's icon and splashscreens. While the examples may hard-code this, its probably best to not do that.

Here's an abridged example that shows both of these:
extern char *__progname;
. . .
QString assetPath = QString("/apps/%1/public/assets/images/").arg(__progname);

int retVal = uds_register_client(udsHandle_, "my-app", "", assetPath.toLatin1().constData());
if(retVal == UDS_SUCCESS) {
    int serviceId = uds_get_service_id(udsHandle_);
    int status = uds_get_service_status(udsHandle_);
    qDebug() << "uds_register_client call successful with" << serviceId << "as serviceId and" << status << "as status";

    if(status == UDS_REGISTRATION_EXISTS) {
        // do existing registration stuff
    }
    else {
        // do new registration stuff
    }
}
else {
    // handle the failure
}

Just keep in mind that the "new" vs "existing" results from this process ONLY tell you if the registration itself is new or existing. Contrary to the recommendations of the docs, they should NOT be relied upon to tell you whether ANYTHING ELSE in the setup process has been successfully completed. At best, it can be used to determine the order in which you try certain operations.

Account ID

You need to register an account ID with the system's Account Service prior to adding an account to the Hub. This account isn't really used for anything but its ID, and as a way for the system to connect the Hub to the Notification service so that your app's Hub items can notify the user correctly.

While the docs may suggest you "remember" your account ID after adding it the first time, it really isn't a good idea to to this. Its better to put some metadata in your account entry so you can actually find it within the system later. This also has the side-benefit of making it possible for other apps (such as notification management apps) to easily find your account ID as well.

Here's the recommended procedure for setting up your account ID:
  • Call AccountService::accounts() to get a list of accounts in the system
    • If the list is empty, fail with an error
  • Iterate through this list, looking for entries that match the specific metadata for your application (likely Account::isExternalData() will return true, plus some other known property)
    • Save the value of the first match. This is the ID you'll use later.
    • Keep a list of any other matches, these are leftover cruft from past mistakes. (Ideally this list will be empty, but in reality it may not be.)
  • Iterate through the list of "extra" matches, doing the following:
    • Call uds_account_removed(), using the safe practices described above.
    • If and only if this succeeds, use AccountService::deleteAccount to delete it from the AccountService
  • If you found a matching account ID:
    • Call AccountService::account() to get the data for it
    • Update its properties to be certain it matches what your app currently expects
    • Call AccountService::updateAccount() on it
    • Note: If you skip this step, someday you'll have users start complaining of a strange "You need to update your account password for YourAppName" notification that comes out of nowhere, once or twice a day.
  • If you didn't find a matching account ID:
    • Get a reference to the "external" provider with AccountService::provider()
    • Prepare the Account object for your app's account
    • Call AccountService::createAccount() to create it
While not mentioned at every step above, make sure you check for errors on every single API call you make. If one fails, then its best to fail the whole operation and try again.

Adding your account

Using the account ID created in the previous section, the procedure for creating an account is fairly straightforward:
  • Create an instance of the account data structure (uds_account_data_t) using uds_account_create()
  • Set all of your account's properties using the uds_account_data_set_XXXX() functions
    • Keep in mind that these functions are really just setting members of a hidden struct full of numeric and pointer types. For any strings, make sure that they're either constants or will live until the operation is complete. Don't do an inline "foo.toUtf8().constData()" on a QString. If your data is coming from a QString, use toUtf8() to get a QByteArray on a separate line from setting the account properties.
  • Using a retry loop and the error-handling guidance from above, do the following:
    • Make sure you have a valid handle
    • If your hub registration is NEW, then:
      • Call uds_account_added() to try creating the account
      • If this call is successful, you're done.
      • If this call fails with a timeout or disconnect error, clean up your handle and loop around
      • If this call fails for any other reason, you failed to add your account
    • If your hub registration is EXISTING, then:
      • Call uds_account_updated() to update your account data
      • If this call is successful, you're done.
      • If this call fails with a timeout or disconnect error, clean up your handle and loop around
      • If this call fails for any other reason:
        • Call uds_account_added() to try creating the account
        • If this call is successful, you're done.
        • If this call fails with a timeout or disconnect error, clean up your handle and loop around
        • If this call fails for any other reason, you failed to add your account
  • Destroy the data structure with uds_account_data_destroy()

Adding account actions

Once you've successfully added your account, use the following procedure to add account-level actions:
  • Create an instance of the action data structure (uds_account_action_data_t) using uds_account_action_data_create()
  • Set all of the action's properties using the uds_account_action_data_set_XXXX() functions
  • Using a retry loop and the error-handling guidance from above, do the following:
    • Make sure you have a valid handle
    • Call uds_register_account_action() to try creating the action
    • If this call is successful, you're done.
    • If this call fails with a timeout or disconnect error, clean up your handle and loop around
    • If this call fails for any other reason:
      • Call uds_ update_account_action() to try updating the action
      • If this call is successful, you're done.
      • If this call fails with a timeout or disconnect error, clean up your handle and loop around
      • If this call fails for any other reason, you failed to add the action
  • Destroy the data structure with uds_account_action_data_destroy()
Note: We have to always follow the "try adding, then try updating" pattern for actions even if we know they won't be there. This is because the update calls seem to succeed on non-existent actions.


Adding context actions

Item context actions are added exactly the same way as account actions. The only difference is that the function and data structure names are slightly different.

Updating inbox items

This is the most frequent operation you are likely to do. It follows a similar pattern as everything above, with a preferred approach of "try updating, then try adding."

There is something you do need to keep in mind here, which is unlikely to be an issue with any of the other operations. There is a size limit on PPS messages, and by extension, the data that you send to the hub when you post an inbox item update. To be safe, always enforce a reasonable maximum length on the message content you put in an inbox item. If you don't, you may wind up sending the hub corrupted data, and bad things may happen.

Handling language changes

Assuming that your application is localized and has translations for all of the hub account and item context actions it provides, you should keep track of device language changes in your background service. When the device language changes, you should update all of your hub account and item context actions so they have display text in the new language. Retry loops are extremely important here, since timeout errors are very common during language changes.

While there is an API for monitoring language changes, it seems to have been placed within the Cascades (libbbcascades) library. If your background service does not link to this library, and it probably shouldn't, you will need to listen for language changes a little differently. Thankfully, like everything else on BlackBerry 10, there's a PPS object you can monitor for this. Here's a little code snippet illustrating some of the less obvious bits of how to do this:

MyApp::MyApp(bb::Application *app) : QObject(app)
{
    . . .
    csLocalePpsObject_ = new bb::PpsObject("/pps/services/confstr/_CS_LOCALE?wait,delta", this);
    connect(csLocalePpsObject_, SIGNAL(readyRead()), this, SLOT(onCsLocalePpsReadyRead()));

    if(!csLocalePpsObject_->open(bb::PpsOpenMode::Subscribe)) {
        qWarning() << "Could not connect to _CS_LOCALE PPS object:" << csLocalePpsObject_->error() << csLocalePpsObject_->errorString();
        delete csLocalePpsObject_;
        csLocalePpsObject_ = NULL;
    }

    onSystemLanguageChanged(QLocale());
    . . .
}


void MyApp::onCsLocalePpsReadyRead()
{
    bool readOk;
    QByteArray data = csLocalePpsObject_->read(&readOk);
    if(!readOk) { return; }

    bool decodeOk;
    const QVariantMap map = bb::PpsObject::decode(data, &decodeOk);
    if(!decodeOk) { return; }

    const QVariantMap state = map["@_CS_LOCALE"].toMap();
    if(state.isEmpty()) { return; }
    if(state.contains("_CS_LOCALE")) {
        QString localeName = state["_CS_LOCALE"].toString();
        QLocale locale(localeName);
        onSystemLanguageChanged(locale);
    }
}

void MyApp::onSystemLanguageChanged(const QLocale &locale)
{
    QString localeString = locale.name();
    QString filename = QString("MyApp_%1").arg(localeString);
    if (translator_.load(filename, "app/native/qm")) {
        QCoreApplication::instance()->installTranslator(&translator_);

        emit languageChanged();
    }
}

Your hub integration class can then connect to the languageChanged() signal, and use it as a trigger for updating all of your account and item context actions.

Common Issues

These are problems related to hub integration that users may start complaining about after your app is released, if you do something wrong or are simply unlucky:
  • Your app appears twice in the hub's user-visible account list
    • You were insufficiently paranoid about error handing in the code that created an account ID and/or added/updated your account entry.
  • There are hub message items from your app that the user can neither delete nor mark as read
    • You didn't make sure your handle was correctly initialized and registered before adding inbox items. As such, these items got added in a way that they're not associated with any account. They may still trigger your app's invoke request to open them, but you have no way to actually update or remove them. Always make sure to call uds_init() and uds_register_client() successfully every single time you're starting with a fresh handle, or this can happen.
  • A strange "You need to update your account password for YourAppName" notification annoys some users, once or twice a day, with no obvious cause.
    • You didn't call AccountService::updateAccount() on your account entry. In cases of device backup/restore (and perhaps others), a flag gets set on all accounts telling the system to prompt the user for password update. Yes, even when it doesn't make any sense. Making this update call will clear that flag.
  • Your app doesn't appear in the hub at all
    • The hub was overloaded when you tried to run your initialization code, and you didn't keep retrying for long enough.
For all these issues, I've described the most common cause that you can actually do something about. With enough users, however, it is inevitable that you'll find some for whom these issues still seem to happen no matter how robust and error-free your hub integration code is. Sometimes, the hub simply won't cooperate. Hopefully, if you've done everything right, this will be rare.

No comments: