Reverse engineering and fixing a streaming app

What happens when a major TV network decides to soft-block an older version of their own streaming app? Yep, we make it work again!

Reverse engineering and fixing a streaming app

What happens when a major TV network decides to soft-block an older version of their own streaming app? Yep, we make it work again!

Story time!

I subscribe to a streaming service for a major TV network in Brazil. Besides being able to watch their channel live, I can also enjoy some movies and series from them, as well as imported ones (with proper subtitles). There's a catch, however: they do not support the Fire TV.

You see, before their service even became relevant for me and my family, I've added Fire TV sticks to pretty much all TVs around here. They run the basic stuff: YouTube (or an ad-free version of it), Plex, Disney+, Netflix, Prime Video, etc. Recently I added this major TV network app to this list, and enjoyed it so far, but their app was never available for the Fire TV: we had to side-load it by downloading the APK from a third-party and installing it manually. It also had to be a very specific version: 2.4.4.

In the last months the app started stating it was an older version and that we should update to a newer one. This, however, isn't possible: all newer versions won't load correctly or you just won't be able to sign in to your account, with a generic error on your screen. This wasn't a big deal though: you could just say "no" to the update and carry on with your business.

Unfortunately, somewhere around last month, this TV operator decided that this had to come to an end: they soft-blocked version 2.4.4 by stating that users must update, and as such blocking the user from watching their streams with the app. As you can imagine, many customers complained against this change, including myself. They do provide an alternative though: if you do it in some public manner (like I did), they send you a Roku Express for free (and no strings attached (as far as they say)) to test their new app there. Sure enough, it works great there - the Roku is a good platform with great performance. I actually installed it in my living room and have been using it for a few days - so far, so good.

But this isn't a solution. This is, IMHO, a bad decision, as, by not supporting Fire TV devices, you're basically ignoring a nice market share in Brazil. Also, this got me curious: why was the old version blocked? And can we bypass it? Also, should you do it?

Disclaimer

I'll be hiding the TV network's name and logos, as well as any content they provide on their platform in any image or code from now one. I do not endorse any kind of hacking for illegal reasons, such as removing content restrictions and unauthorized access. This reverse engineering process was done for fun and because I was bored. I won't provide any binary files.

The old version

The old version got soft-blocked. You actually see a screenshot of this here:

"Atualizar agora" (update now) seems to be the only way forward

Originally this screen had a second button to bypass the update requirement. I was curious: something, somewhere was changing its visibility. Maybe an API response? Can we change it?

Well, Fire TV apps are just Android apps. This means you can actually use something like apktool to extract their code and even rebuild the package itself. If you want to know where I learned to do this, it's very simple: I just followed a guide. Yep, and it's this one. Anyway, by extracting the code, I was able to grep through the Smali code and find a lot of really interesting things:

$ grep -ri versao *
(...)
smali/com/foobar/foobartv/versioncontrol/VersionControlActivity.smali:    const-string v0, "controle_de_versao_requerido"
smali/com/foobar/foobartv/versioncontrol/VersionControlActivity.smali:    const-string v0, "controle_de_versao_sugerido"
smali/com/foobar/foobartv/versioncontrol/VersionControlActivity.smali:    const-string v3, "controle_de_versao"
smali/com/foobar/foobartv/versioncontrol/VersionControlActivity.smali:    const-string v2, "controle_de_versao"
"versao" (actually "versão") means version in Portuguese

Those strings mean "required version control", "suggested version control" and "version control". This gave me a starting point: the version control activity.

Note: I am not a mobile developer and I have no experience with reverse engineering Android apps. This is me trying to thinker with stuff and checking what happens. You have been warned.

My first attempt was to replace this controle_de_versao_requerido with the controle_de_versao_sugerido string. I mean, it can't be bad, right? This didn't change anything though: same screen, same issue.

The second idea I had was based on the visibility. Something was changing the second button to make it invisible. The question is what. A quick search for "visibility" got me this very nice piece of code:

.method private final m()V
    .locals 2

    .line 105
    sget v0, Lcom/foobar/foobartv/a$a;->activity_version_control_button_update_later:I

    invoke-virtual {p0, v0}, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;->c(I)Landroid/view/View;

    move-result-object v0

    check-cast v0, Landroid/support/v7/widget/AppCompatTextView;

    const-string v1, "activity_version_control_button_update_later"

    invoke-static {v0, v1}, Lkotlin/jvm/internal/Intrinsics;->checkExpressionValueIsNotNull(Ljava/lang/Object;Ljava/lang/String;)V

    iget-boolean v1, p0, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;->o:Z

    if-eqz v1, :cond_0

    const/16 v1, 0x8

    goto :goto_0

    :cond_0
    const/16 v1, 0x0

    :goto_0
    invoke-virtual {v0, v1}, Landroid/support/v7/widget/AppCompatTextView;->setVisibility(I)V

    return-void
.end method

See the last call, the setVisibility one? It is being called with two arguments: v0 and v1. It seems that v0 might be an object, while v1 is an integer argument (based on the 0x8 and 0x0 values it receives). This makes sense, as if you google it, you'll find similar pieces of code. This code is conditional though: it's based off v1. If v1 is equal to zero (see docs), it'll jump to cond_0, setting v0 to 0x0. Otherwise the code will continue, setting v0 to 0x8. Finally, whatever value is there, will be used as the new visibility. So this would be something like this in Java:

whatever->setVisibility(v1 == 0 ? 0x0 : 0x8)

Then comes the question: what the hell is v1? Well, v1 seems to be some kind of property: o:Z. I have no idea what it is, but it seems to be storing a vital piece of information: whether the button to ignore the old version warning should or not be visible. So maybe it holds the information of we can or not bypass the version requirement? Let's dig deeper!

The "Oz" variable

Not this Oz!

By looking for ->o:Z, we can see multiple uses of this "property" (or whatever it is). And there are only two calls setting its boolean value, and one of them give us a huge hint:

.method protected onRestoreInstanceState(Landroid/os/Bundle;)V
    .locals 2
    .param p1    # Landroid/os/Bundle;
        .annotation build Lorg/jetbrains/annotations/Nullable;
        .end annotation
    .end param

    .line 57
    invoke-super {p0, p1}, Lcom/foobar/foobartv/activity/BaseActivity;->onRestoreInstanceState(Landroid/os/Bundle;)V

    const/4 v0, 0x0

    if-eqz p1, :cond_0

    const-string v1, "instance_state_update_required"

    .line 58
    invoke-virtual {p1, v1, v0}, Landroid/os/Bundle;->getBoolean(Ljava/lang/String;Z)Z

    move-result v0

    .line 59
    :cond_0
    iput-boolean v0, p0, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;->o:Z

    .line 60
    invoke-direct {p0}, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;->m()V

    return-void
.end method

Whatever is being done here, seems to be update related, as you can see the instance_state_update_required. This string is then used on a getBoolean call, whose result is being stored on v0. Then this same boolean value is being set on the o:Z property.

Now comes the question: if we assume this property means "update is required", what happens if we override with 0x0 (false)? Well, we can try! As I said before, there are only two calls setting o:Z, so it should be pretty easy to mod them by setting their values to 0x0 using const/4 right before they are used in the property:

.method protected onRestoreInstanceState(Landroid/os/Bundle;)V
# (...)
    .line 59
    :cond_0
    const/4 v0, 0x0
    iput-boolean v0, p0, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;->o:Z
# (...)
.end method

.method protected onCreate(Landroid/os/Bundle;)V
# (...)

    const/4 v0, 0x0
    iput-boolean v0, p0, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;->o:Z

# (...)
.end method

So, with this property hard-wired to false, what happens with the app?

"Atualizar depois" (update later) is back!

Aha! The button is back! Nice! But it doesn't work: if you click on it, the app doesn't do anything (or crashes, I don't really recall at this moment, sorry). Let's go deeper!

Making it work

We now carry on by looking for param_update_is_required on the code. I mean, it's a good start point as any other, right? Well, this brought me to a very long function:

# virtual methods
.method public final a(Lcom/foobar/foobartv/n/h;)V
    .locals 4
    .param p1    # Lcom/foobar/foobartv/n/h;
        .annotation build Lorg/jetbrains/annotations/Nullable;
        .end annotation
    .end param
    .annotation system Ldalvik/annotation/Signature;
        value = {
            "(",
            "Lcom/foobar/foobartv/n/h<",
            "Lcom/foobar/foobartv/n/e/b/a;",
            ">;)V"
        }
    .end annotation

    const/4 v0, 0x0

    if-eqz p1, :cond_0

    .line 119
    invoke-virtual {p1}, Lcom/foobar/foobartv/n/h;->a()Lcom/foobar/foobartv/n/h$a;

    move-result-object v1

    goto :goto_0

    :cond_0
    move-object v1, v0

    :goto_0
    if-nez v1, :cond_1

    goto/16 :goto_2

    :cond_1
    sget-object v2, Lcom/foobar/foobartv/splash/a;->a:[I

    invoke-virtual {v1}, Lcom/foobar/foobartv/n/h$a;->ordinal()I

    move-result v1

    aget v1, v2, v1

    const/4 v2, 0x1

    if-eq v1, v2, :cond_9

    const/4 v3, 0x2

    if-eq v1, v3, :cond_3

    const/4 p1, 0x3

    if-eq v1, p1, :cond_2

    goto/16 :goto_2

    .line 146
    :cond_2
    iget-object p1, p0, Lcom/foobar/foobartv/splash/SplashActivity$b;->a:Lcom/foobar/foobartv/splash/SplashActivity;

    new-instance v0, Landroid/content/Intent;

    invoke-virtual {p1}, Lcom/foobar/foobartv/splash/SplashActivity;->getBaseContext()Landroid/content/Context;

    move-result-object v1

    const-class v2, Lcom/foobar/foobartv/activity/NoConnectionActivity;

    invoke-direct {v0, v1, v2}, Landroid/content/Intent;-><init>(Landroid/content/Context;Ljava/lang/Class;)V

    invoke-virtual {p1, v0}, Lcom/foobar/foobartv/splash/SplashActivity;->startActivity(Landroid/content/Intent;)V

    .line 147
    iget-object p1, p0, Lcom/foobar/foobartv/splash/SplashActivity$b;->a:Lcom/foobar/foobartv/splash/SplashActivity;

    invoke-virtual {p1}, Lcom/foobar/foobartv/splash/SplashActivity;->finish()V

    goto :goto_2

    .line 124
    :cond_3
    invoke-virtual {p1}, Lcom/foobar/foobartv/n/h;->b()Ljava/lang/Object;

    move-result-object v1

    check-cast v1, Lcom/foobar/foobartv/n/e/b/a;

    if-eqz v1, :cond_4

    invoke-virtual {v1}, Lcom/foobar/foobartv/n/e/b/a;->b()Lcom/foobar/foobartv/n/e/b/e;

    move-result-object v0

    .line 125
    :cond_4
    invoke-virtual {p1}, Lcom/foobar/foobartv/n/h;->b()Ljava/lang/Object;

    move-result-object p1

    check-cast p1, Lcom/foobar/foobartv/n/e/b/a;

    sput-object p1, Lcom/foobar/foobartv/TVApplication;->a:Lcom/foobar/foobartv/n/e/b/a;

    const/4 p1, 0x0

    if-eqz v0, :cond_5

    .line 128
    invoke-virtual {v0}, Lcom/foobar/foobartv/n/e/b/e;->a()I

    move-result v1

    goto :goto_1

    :cond_5
    const/4 v1, 0x0

    :goto_1
    const/16 v3, 0x277f

    if-le v1, v3, :cond_6

    .line 129
    iget-object p1, p0, Lcom/foobar/foobartv/splash/SplashActivity$b;->a:Lcom/foobar/foobartv/splash/SplashActivity;

    new-instance v0, Landroid/content/Intent;

    invoke-virtual {p1}, Lcom/foobar/foobartv/splash/SplashActivity;->getBaseContext()Landroid/content/Context;

    move-result-object v1

    const-class v3, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;

    invoke-direct {v0, v1, v3}, Landroid/content/Intent;-><init>(Landroid/content/Context;Ljava/lang/Class;)V

    const-string v1, "param_update_is_required"

    .line 130
    invoke-virtual {v0, v1, v2}, Landroid/content/Intent;->putExtra(Ljava/lang/String;Z)Landroid/content/Intent;

    move-result-object v0

    .line 129
    invoke-virtual {p1, v0}, Lcom/foobar/foobartv/splash/SplashActivity;->startActivity(Landroid/content/Intent;)V

    .line 131
    iget-object p1, p0, Lcom/foobar/foobartv/splash/SplashActivity$b;->a:Lcom/foobar/foobartv/splash/SplashActivity;

    invoke-virtual {p1}, Lcom/foobar/foobartv/splash/SplashActivity;->finish()V

    goto :goto_2

    :cond_6
    if-eqz v0, :cond_7

    .line 134
    invoke-virtual {v0}, Lcom/foobar/foobartv/n/e/b/e;->b()I

    move-result p1

    :cond_7
    if-le p1, v3, :cond_8

    .line 135
    iget-object p1, p0, Lcom/foobar/foobartv/splash/SplashActivity$b;->a:Lcom/foobar/foobartv/splash/SplashActivity;

    new-instance v0, Landroid/content/Intent;

    invoke-virtual {p1}, Lcom/foobar/foobartv/splash/SplashActivity;->getBaseContext()Landroid/content/Context;

    move-result-object v1

    const-class v2, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;

    invoke-direct {v0, v1, v2}, Landroid/content/Intent;-><init>(Landroid/content/Context;Ljava/lang/Class;)V

    const/16 v1, 0x1100

    invoke-virtual {p1, v0, v1}, Lcom/foobar/foobartv/splash/SplashActivity;->startActivityForResult(Landroid/content/Intent;I)V

    goto :goto_2

    .line 140
    :cond_8
    iget-object p1, p0, Lcom/foobar/foobartv/splash/SplashActivity$b;->a:Lcom/foobar/foobartv/splash/SplashActivity;

    invoke-static {p1}, Lcom/foobar/foobartv/splash/SplashActivity;->a(Lcom/foobar/foobartv/splash/SplashActivity;)V

    :cond_9
    :goto_2
    return-void
.end method

Honestly, to me, this function doesn't make any sense. But there's an interesting line on it: if-le v1, v3, :cond_6. And if you take a look at the block of code right after it, and the one after the cond_6 label, you see that one has the update required string, and the other does not. So... how about we force to go through the path that does not have the required update stuff? And that's an easy mod:

if-le v1, v3, :cond_6
goto :cond_6 # Yep, a single goto.

Ironically, this is all that was needed. For real! Once I added that, the app loads just fine, as you can see here from the about screen here:

Ricardo from the future here: I tried making a video of it loading and everything. Turns out I'm terrible on editing videos! :)

But why not a newer version?

You see, having an old version pinging their services even though they blocked is probably a bad idea. Although very unlikely, they could find me through their logs and eventually block my account for this. This would be very annoying. They could also hire me, but I find that very unlikely as well :)

So here comes the question: how about we do this with a newer version? We don't have to unlock it (as it's a recent one, so that's fine), but we have to figure out why the hell the app does not work. This is what happens when I try to sign in using a code:

"An error occurred when loading instructions to access your account on the TV. Try again later."
Ricardo from the future: yeah, bad photos from now on. Sorry, this issue only happens on real hardware, so no screenshots as I don't have any mean of capturing HDMI right now.

The app also didn't allow me to not use a username and password for login, as somehow this option isn't selectable. This is probably a bug with the Fire TV only. I found the code responsible for opening the proper activity for logging in with an activation code (like Plex does) and with an email and inverted them. Sure enough, the app asks me for my credentials, but unfortunately it also fails to sign me in:

So it has to be something on a lower level, something used by both. Maybe network? Maybe a flag? I started looking for anything that looked weird, disabling specifics parts of the code to make sure it could slowly debug every single call on the Smali code. This was slow, and it had to be done on real hardware, as the normal virtual Android TV works just fine. By reverse engineering where I was getting the error from, I saw a huge function with a bunch of try-catches just like this in the activity responsible for the sign-in process:

:try_start_0
invoke-static {p3}, Lkotlin/ResultKt;->throwOnFailure(Ljava/lang/Object;)V
:try_end_0
.catch Ljava/lang/Exception; {:try_start_0 .. :try_end_0} :catch_1
Smali is so weird!

I mean, for Java/Kotlin code this is fine, but the weird part is what happened when an exception got caught:

:catch_0
move-object p1, p0

.line 9
:catch_1
iget-object p1, p1, Lcom/foobar/foobarid/connect/androidtv/signin/code/SignInCodePresenter;->view:Lcom/foobar/foobarid/connect/androidtv/signin/code/SignInCodeContracts$View;

invoke-interface {p1}, Lcom/foobar/foobarid/connect/androidtv/signin/code/SignInCodeContracts$View;->showError()V
Did you say "show error"? :)

What is happening here is that all exceptions are, in one way or another, being caught and sent all the way down to this function, which is responsible to show an error (probably a generic one?). This makes perfect sense, as depending as how you code your projects, you could have a centralized exception handler.

Anyway, I started to wonder: what would happen if I actually stop catching exceptions? We all know that if an exception is not caught, it will eventually go so high in the stack that the VM will get it. So, if I removed all catch instructions from this function, what would happen? Well, this happens:

E/AndroidRuntime( 7819): FATAL EXCEPTION: main
E/AndroidRuntime( 7819): Process: com.foobar.foobartv, PID: 7819
E/AndroidRuntime( 7819): com.foobar.foobarid.connect.core.exception.FoobarIDConnectException
E/AndroidRuntime( 7819):        at com.foobar.foobarid.connect.devices.deviceactivationcode.DeviceActivationCodeServiceImpl.resumeWithException(DeviceActivationCodeServiceImpl.kt:2)
E/AndroidRuntime( 7819):        at com.foobar.foobarid.connect.devices.deviceactivationcode.DeviceActivationCodeServiceImpl.access$resumeWithException(DeviceActivationCodeServiceImpl.kt:1)
E/AndroidRuntime( 7819):        at com.foobar.foobarid.connect.devices.deviceactivationcode.DeviceActivationCodeServiceImpl$performRequest$1.invoke(DeviceActivationCodeServiceImpl.kt:9)
E/AndroidRuntime( 7819):        at com.foobar.foobarid.connect.devices.deviceactivationcode.DeviceActivationCodeServiceImpl$performRequest$1.invoke(DeviceActivationCodeServiceImpl.kt:1)
E/AndroidRuntime( 7819):        at com.foobar.foobarid.connect.core.networking.client.DeviceValidationHttpClient.request(DeviceValidationHttpClient.kt:7)
E/AndroidRuntime( 7819):        at com.foobar.foobarid.connect.devices.deviceactivationcode.DeviceActivationCodeServiceImpl.performRequest(DeviceActivationCodeServiceImpl.kt:4)
E/AndroidRuntime( 7819):        at com.foobar.foobarid.connect.devices.deviceactivationcode.DeviceActivationCodeServiceImpl.access$performRequest(DeviceActivationCodeServiceImpl.kt:1)
E/AndroidRuntime( 7819):        at com.foobar.foobarid.connect.devices.deviceactivationcode.DeviceActivationCodeServiceImpl.execute(DeviceActivationCodeServiceImpl.kt:6)
E/AndroidRuntime( 7819):        at com.foobar.foobarid.connect.androidtv.signin.code.SignInCodeInteractor.fetchDeviceActivationCode(SignInCodeInteractor.kt:12)
E/AndroidRuntime( 7819):        at com.foobar.foobarid.connect.androidtv.signin.code.SignInCodePresenter$fetchDeviceActivationCode$signInCode$1.invokeSuspend(SignInCodePresenter.kt:4)
E/AndroidRuntime( 7819):        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:3)
E/AndroidRuntime( 7819):        at kotlinx.coroutines.z0.run(DispatchedTask.kt:22)
E/AndroidRuntime( 7819):        at kotlinx.coroutines.scheduling.CoroutineScheduler.w(CoroutineScheduler.kt:1)
E/AndroidRuntime( 7819):        at kotlinx.coroutines.scheduling.CoroutineScheduler$c.c(CoroutineScheduler.kt:4)
E/AndroidRuntime( 7819):        at kotlinx.coroutines.scheduling.CoroutineScheduler$c.m(CoroutineScheduler.kt:4)
E/AndroidRuntime( 7819):        at kotlinx.coroutines.scheduling.CoroutineScheduler$c.run(CoroutineScheduler.kt:1)
E/AndroidRuntime( 7819): Caused by: com.foobar.foobarid.connect.core.networking.error.InvalidDeviceTokenException: Invalid Device Token
E/AndroidRuntime( 7819):        ... 12 more
Gotta love logcat!

Gotcha! That is the exception! It's an InvalidDeviceTokenException (within the more generic one). The device token is invalid... but why?

Gimme tokens!

Once we got the exception, we had to figure out what was triggering it. That took a simple file search:

.method public request(Lokhttp3/y;Lkotlin/jvm/functions/Function1;)V
# (...)

    invoke-virtual {v0}, Lcom/foobar/foobarid/connect/core/model/FoobarIdConnectSettings;->getDeviceToken$foobarid_connect_tvRelease()Ljava/lang/String;

    move-result-object v0

    if-eqz v0, :cond_0

# (...)

    .line 7
    :cond_0
    sget-object p1, Lkotlin/Result;->Companion:Lkotlin/Result$Companion;

    new-instance p1, Lcom/foobar/foobarid/connect/core/networking/error/InvalidDeviceTokenException;

    const/4 v0, 0x1

    const/4 v1, 0x0

    invoke-direct {p1, v1, v0, v1}, Lcom/foobar/foobarid/connect/core/networking/error/InvalidDeviceTokenException;-><init>(Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V

    invoke-static {p1}, Lkotlin/ResultKt;->createFailure(Ljava/lang/Throwable;)Ljava/lang/Object;

# (...)

.end method

This function seems to be responsible for doing HTTP requests using OkHttp. Nice. It works like this:

  1. Call the getDeviceToken method and stores its result in v0.
  2. If v0 is zero (or null in this case), jump to cond_0. Otherwise continue (we'll check this part later)
  3. Create an instance of InvalidDeviceTokenException and throw it.

So, whatever getDeviceToken is doing, it is returning null. This got me curious: what is a device token? Is it something they created themselves? Is it a device-sourced information? Since the app works on standard Android TVs and not on the Fire TV, this could to be something Google-related, right? Well, here's what it is done with it:

.method public request(Lokhttp3/y;Lkotlin/jvm/functions/Function1;)V
# (...)

    .line 2
    invoke-virtual {p1}, Lokhttp3/y;->i()Lokhttp3/y$a;

    move-result-object p1

    .line 3
    iget-object v1, p0, Lcom/foobar/foobarid/connect/core/networking/client/DeviceValidationHttpClient;->foobarIDSettings:Lcom/foobar/foobarid/connect/core/model/FoobarIdConnectSettings;

    invoke-virtual {v1}, Lcom/foobar/foobarid/connect/core/model/FoobarIdConnectSettings;->getAppId()Ljava/lang/String;

    move-result-object v1

    const-string v2, "X-APP-ID"

    invoke-virtual {p1, v2, v1}, Lokhttp3/y$a;->a(Ljava/lang/String;Ljava/lang/String;)Lokhttp3/y$a;

    const-string v1, "X-DEVICE-TOKEN"

    .line 4
    invoke-virtual {p1, v1, v0}, Lokhttp3/y$a;->a(Ljava/lang/String;Ljava/lang/String;)Lokhttp3/y$a;

    .line 5
    invoke-virtual {p1}, Lokhttp3/y$a;->b()Lokhttp3/y;

    move-result-object p1

    .line 6
    iget-object v0, p0, Lcom/foobar/foobarid/connect/core/networking/client/DeviceValidationHttpClient;->httpClient:Lcom/foobar/foobarid/connect/core/networking/client/HttpClient;

    invoke-interface {v0, p1, p2}, Lcom/foobar/foobarid/connect/core/networking/client/HttpClient;->request(Lokhttp3/y;Lkotlin/jvm/functions/Function1;)V

    return-void

# (...)

.end method

Based on this code, what I can tell is that it's being used as custom header of some sort together with the app id. Interesting. Let's check what is getDeviceToken doing:

.method public final getDeviceToken$foobarid_connect_tvRelease()Ljava/lang/String;
    .locals 1
    .annotation build Lorg/jetbrains/annotations/Nullable;
    .end annotation

    .line 1
    iget-object v0, p0, Lcom/foobar/foobarid/connect/core/model/FoobarIdConnectSettings;->deviceToken:Ljava/lang/String;

    return-object v0
.end method

That seems to be just a private field read, so it has to be getting it from somewhere. Despites my efforts, I wasn't really able to find its source, but from what I could see it is analytics-related. So I decided to do something dumb: set it to a constant. How about an empty string? Would that work? I mean, it's not null!

.method public final getDeviceToken$foobarid_connect_tvRelease()Ljava/lang/String;
    .locals 1
    .annotation build Lorg/jetbrains/annotations/Nullable;
    .end annotation

    .line 1
    # iget-object v0, p0, Lcom/foobar/foobarid/connect/core/model/FoobarIdConnectSettings;->deviceToken:Ljava/lang/String;

    const-string v0, ""

    return-object v0
.end method
Here goes nothing!

And, to my honest surprise, it works!

It seems I can't hold a camera steady. Ops.

You can sign in and watch everything just as normal. Here's the version I modified to make it work:

Finally. Geez, what a messy journey.

Old memes, yey!
Ricardo from the future here: turns out that hacking this device token function does not make everything work, only the token-based sign-in. The email and password login option still fails, but noone uses that... right? Right! :)

Addressing the elephant in the room

The big question is: should you do it? And, to be honest, no, you should not.

Well, it depends. If you don't care about their terms of use (which most likely forbid you to do this) and you don't care about maybe getting kicked or even banned out of their service, sure, be my guest. My reasons for doing this modification were clearly out of curiosity, fun and to learn something new and different. This was my first time modifying Android apps, so this was quite an interesting journey.

But besides that, we can clearly see the app works just fine on the Fire TV: it's just a matter of fixing a few bugs on their side. Based on my experience, it seems that not supporting the Fire TV was a product decision, and as such the developers never get to know about those issues.

Well, maybe one day - we can all hope! For now, I'll stick with their own device (the Roku Express they gave me) for watching their streaming service. Maybe the Roku is an interesting platform to have some fun as well, who knows! :)