We’ve already documented how (occasionally) painful it can be to develop for Samsung Tizen devices. We want to be fair, so here’s a follow-up about developing for LG WebOS.
Fortunately, LG has slightly better tooling than Samsung, so we don’t have to deal with things like having the correct IDE and SDK versions to be able to install our application on certain devices. Here, we can get away with one SDK and its set of CLI scripts. But, as always, the devil’s in the details - in this case, in the native APIs and weird platform bugs. Tizen devices suffer from these issues as well, but that’s another story.
So let’s get to it.
Async APIs
When you get started with WebOS, the first surprise is the API. Unlike Tizen, WebOS developers decided that the whole API should be asynchronous.
There not necessarily anything wrong with that, but we use information from the APIs to load the correct drivers (our own SDK layer that handles device differences), and to initialize the application’s state. As a consequence, we have to wait until we get all of the information, and only after that can we boot the application.
NOTE: When we talk about device in our context, we mean any physical TV, browser, or mobile phone. But when we talk about Device, we mean the driver implementation responsible for handling the specifics of each platform.
To give you a better idea, the boot of our application looks like this:
// This is the driver for the platform that fetches, and then provides, information about the device.
// For example, the driver can provide information about firmware, network connection, and more.
import Device from 'drivers/Device';
import store from './store';
import initInterceptors from './API/interceptors';
// ... more imports
// This has to be done first so the network communication works
// as expected and the store is available for data collected from a device
initInterceptors();
initStore();
Device.isReady() // waiting for Device (WebOS) API calls to return with results
.then(() => bootTheApp())
When we first implemented this approach and tested it, it all worked fine because we knew we shouldn’t access anything from the Device until we’ve collected the necessary data. We also had to be careful about the two init calls (initInterceptors() and initStore()) and their imports.
As time passed, we forgot about this restriction, and used Device data before it was collected … and the application crashed. To make things worse, it didn’t crash every time because of the asynchronous nature of the whole process. We had introduced a rare race condition.
That’s a topic for another time, but understand that we really didn’t know what was going on, and it was really painful to find out after a while that the real issue was that we were using the Device data too early.
Because we support a lot of platforms, we have a lot of drivers. Checking every single driver’s property to see if the data is loaded and throw an exception otherwise would be tedious and easy to forget when adding new properties. So, we came up with a much less error-prone solution and used a proxy to access a device.
function deviceProxy(device: Device): Device {
// These props are statically initialized, or can be called prior to any
// data collection
const propsSafeBeforeReady: any[] = ['isReady', 'isWebsite', 'history', 'addConnectionListener', 'type'];
let deviceReady = false;
const proxyDevice = new Proxy(device, {
get(obj: any, prop) {
if (propsSafeBeforeReady.includes(prop)) {
return obj[prop];
}
if (!deviceReady) {
throw new Error(
`Cannot access Device property "${String(prop)}" before the Device is ready. Wait for Device.isReady()`,
);
}
return obj[prop];
},
});
proxyDevice.isReady().then(() => {
deviceReady = true;
});
return proxyDevice;
}
Now we can simply wrap every Device implementation in the proxy and be sure that if we ever access data before it’s allowed, the app will crash loudly, telling us what we did.
export default deviceProxy(WebOSDevice);
The best part is that we can see the crash as part of our tests.
Simple but effective.
Stuck promises
When our application starts, we fetch some information about the device to determine which driver to load, network status, and a couple other things. As mentioned before, all of this happens asynchronously, so we use Promises. Nothing surprising, right? You may wonder why it’s even worth mentioning. Keep reading….
Here’s a real-world scenario - fetching the connection status. It can look like this:
class WebOSDevice extends Device {
constructor() {
this.connectionStatus = new Promise((resolve) => {
this.subscribeForConnectionStatus('luna://com.webos.service.connectionmanager')
.then(resolve)
.catch(() => {
// Fallback for webOS < 3.x devices which have a different service
this.subscribeForConnectionStatus('luna://com.palm.connectionmanager')
.then(resolve)
.catch((error) => {
// Better to set the connection status to `true` and don't prevent
// user from using the application just because the internal API does not work
this.isConnectedState = true;
resolve();
});
});
});
// connection status Promise handling omitted ...
}
// Here we try to fetch the current connection status
// and also subscribe for any future connection status changes
subscribeForConnectionStatus = (serviceUrl: string): Promise<any> =>
new Promise((resolve, reject) => {
webOS.service.request(serviceUrl, {
method: 'getStatus',
parameters: { subscribe: true },
onSuccess: (result: any) => {
const currentState = this.isConnectedState;
this.isConnectedState = result.isInternetConnectionAvailable;
// If the connection status changes, we are able to immediatelly react.
// We can warn our user that connection has been lost, perform API call retries, ...
if (currentState !== result.isInternetConnectionAvailable) {
this.customConnectionCallback();
}
resolve();
},
onFailure: (error: any) => {
reject(error);
},
});
});
}
It may seem like a totally safe code - we handle possible errors, we don’t block our user for longer than necessary and we also have a fallback in case the internal API fails.
But devices running WebOS 1.x would disagree. In certain instances (one of them was a page reload), we observed a very strange behavior - our application was stuck during boot and would not load unless the user pressed a button on TV’s remote control. Crazy, right? It gets crazier.
So we did what anybody would probably do to find out the root cause - we connected a debugger to the TV to observe what was going on. Of course we were not able to reproduce the issue and the application loaded smoothly every single time. And, of course, when we disconnected the debugger the application was stuck again!
Alright, so the debugger didn’t help. So we added some console logging to see the data flows during the boot. Do you think it helped us discover the root cause? Of course it didn’t! Because the issue was not reproducible anymore.
Apparently a Promise can sometimes get stuck in pending state and never resolve. But, if there is any additional action during the boot, the Promise resolves and the TV doesn’t get stuck. This started to look like a really nasty WebOS bug.
We were a little bit desperate at this point because we were not able to find any decent solution to this problem. Desperate times call for desperate measures, so we decided to add a couple of empty setTimeout calls to see if it would make any difference.
this.connectionStatus = new Promise((resolve) => {
setTimeout(() => {}, 100); // <<< one no-op here
this.subscribeForConnectionStatus('luna://com.webos.service.connectionmanager')
.then(resolve)
.catch(() => {
setTimeout(() => {}, 100); // <<< and second no-op here
// Fallback for webOS < 3.x devices which have a different service
this.subscribeForConnectionStatus('luna://com.palm.connectionmanager')
.then(resolve)
.catch((error) => {
// Better to set the connection status to `true` and don't prevent
// user from using the application just because the internal API does not work
this.isConnectedState = true;
resolve();
});
});
});
Long story short, it immediately helped! To this day we still have no idea why, but this magic just works and we haven’t seen a stuck application boot since.
Magic remote events
More advanced LG TVs have a special accessory - the magic remote. It’s a pretty cool device that allows you to control the TV in mouse-like fashion. You can scroll with a wheel and much more. However, nothing is as perfect as it seems…
We implemented support for the magic remote, tested it, and it seemed to work nicely. Until we noticed that on some specific devices, the scrolling with the magic wheel was broken.
It turns out that WebOS 1.x and 2.x were running an older WebKit engine that used a mousewheel event (which is now obsolete), rather than the new wheel event. And, as specified in the documentation, the delta Y value had a different meaning in both the events. This is what the MDN doc says about a mousewheel event’s wheelDeltaY value:
… with negative values indicating the scrolling movement is either toward the bottom or toward the right, and positive values indicating scrolling to the top or left. …
It is completely the opposite to a wheel event’s deltaY that is positive when scrolling downwards, and negative when scrolling upwards.
To fix our scrolling, we simply needed to detect the presence of wheelDeltaY, which is part of the old mousewheel event, and transform it. We do it only in WebOS drivers so it’s part of our platform SDK and transparent to the rest of the application, which only works with the wheel event’s structure.
function preProcessWheelEvent(e: WheelEvent | MouseWheelEvent): WheelEvent {
if (e.wheelDeltaY && e.wheelDeltaX) {
return {
...e,
deltaY: -e.wheelDeltaY,
deltaX: -e.wheelDeltaX,
};
}
return e;
}
Debugging the debugger again
LG dev tools on older devices with Webkits suffer from similar issues as Tizens but they add one more twist to it - you cannot expand part of HTML in the Elements tab because the click listener crashes due to a bug and tries to call a function that does not exist.
As previously with the Tizen’s Enter key, the fix is pretty simple. You just need to modify the correct code and you’re good to go.
But as you can guess, it can become quite tedious if you want to debug something on older devices. You have to modify the code in several places every single time you open the dev tools … which in reality happens after each deploy of the application to the TV - i.e., very often.
To make our lives easier, we took advantage of Custom Javascript for Websites 2 Chrome extension which can automatically fix your old dev tools when opened.
Here’s the code you need to supply to the extension so your dev tools can be fixed in a second:
// Fix Enter key
window.isEnterKey = function() {
return event.keyCode !== 229 && event.key === "Enter";
}
// Fix HTML expansion in Elements tab
TreeElement.prototype.isEventWithinDisclosureTriangle = function(event) {
var paddingLeftValue = window.getComputedStyle(this._listItemNode).getPropertyValue("padding-left");
var computedLeftPadding =paddingLeftValue ? parseInt(paddingLeftValue.replace('px', '')) : 0;
var left = this._listItemNode.totalOffsetLeft() + computedLeftPadding;
return event.pageX >= left && event.pageX <= left + this.arrowToggleWidth && this.hasChildren;
}
Smart TVs are fun
Development for leanback devices can be difficult and challenging, but it’s also one of the most interesting areas in which we work. It really forces us to understand what’s going on under the hood of Javascript, and it’s also a great deal of fun to come up with unique solutions to unique problems.
Hope you enjoyed another sneak peek to Smart TV world and stay tuned for new articles on our blog!