JuiceSSH - Give me back my pro features

13 déc. 2025

JuiceSSH used to be the best SSH client available on Android until December 2025.

Since then, the purchase made in 2019 is not recognized anymore, and the price went up by 20$. Some users complain in review that after buying it again, the application doesn't get activated. Support is unresponsive, this looks like an exit scam.

Below is a way to make the application work again. This required jadx to understand smali, and will require you ApkTool and jarsigner, which is part of OpenJDK, and you that can install on Windows using choco install openjdk.

You'll also need a JuiceSSH apk, I downloaded one from PureAPK, but feel free to dump your own from your device using adb if you cannot find it. Make sure to verify the hash using virus total/sha256sum if downloading from internet, which should be d1ee811bcd82f25aea0bdc568896d82017ee174d9c4631c123a9d9173c748232 for the last version available, version 3.2.2.

Below are powershell version of the command lines, but you get the idea.

Decompile

The first step is to decompile the dex packed code from the apk.

& "C:\Program Files\OpenJDK\jdk-25\bin\java.exe" -jar d juicessh.apk

Modify smali

You then need to modify the smali of three files, which are detailed below.

smali/com/sonelli/juicessh/models/User.smali

In this file, we'll patch the purchase validation and signature validation, done by the public boolean H() function.

Here is the original version.

public boolean H() {
    try {
        String str = "";
        ArrayList arrayList = new ArrayList();
        for (Purchase purchase : this.purchases) {
            if (!arrayList.contains(purchase.order)) {
                str = str + purchase.product + purchase.state;
                arrayList.add(purchase.order);
            }
        }
        return vg0.b(this.signature, this.sessionIdentifier + this.name + this.email + str + this.disabled.toString());
    } catch (IllegalStateException e) {
        e.printStackTrace();
        return false;
    }
}

Which we'll simply changed with

public boolean H() {
    return true;
}
# virtual methods
.method public H()Z
    .locals 1

    const/4 v0, 0x1
    return v0
.end method

smali/com/sonelli/oi0.smali

In this one, we'll patch the public static boolean d(Object obj) function, who calls the H() previous validation method described above, which now returns true, filter product matching JuiceSSH in purchases list, and check if it the purshase is valid. We'll simply make it return true in any case.

Here is the original version:

public static boolean d(Object obj) {
    if (!obj.getClass().getName().equals(User.class.getName())) {
        return false;
    }
    try {
        if (!((User) obj).H()) {
            return false;
        }
        ArrayList arrayList = new ArrayList();
        for (Purchase purchase : ((User) obj).purchases) {
            if (purchase.product.equals(a())) {
                arrayList.add(purchase);
            }
        }
        Collections.sort(arrayList, new a());
        if (arrayList.size() > 0) {
            if (((Purchase) arrayList.get(arrayList.size() - 1)).state.intValue() == 0) {
                return true;
            }
        }
        return false;
    } catch (NullPointerException e) {
        e.printStackTrace();
        return false;
    }
}

Here is the patched version:

public static boolean d(Object obj) {
    return obj.getClass().getName().equals(User.class.getName());
}
.method public static d(Ljava/lang/Object;)Z
    .locals 3

    # obj.getClass()
    invoke-virtual {p0}, Ljava/lang/Object;->getClass()Ljava/lang/Class;
    move-result-object v0

    # obj.getClass().getName()
    invoke-virtual {v0}, Ljava/lang/Class;->getName()Ljava/lang/String;
    move-result-object v0

    # User.class
    const-class v1, Lcom/sonelli/juicessh/models/User;

    # User.class.getName()
    invoke-virtual {v1}, Ljava/lang/Class;->getName()Ljava/lang/String;
    move-result-object v1

    # compare strings
    invoke-virtual {v0, v1}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z
    move-result v2

    if-nez v2, :cond_true

    const/4 v0, 0x0
    return v0

    :cond_true
    const/4 v0, 0x1
    return v0
.end method

smali/com/sonelli/pi0.smali

Finally, we'll patch the central part of the authentication, which is called each time a pro-feature is triggered to ensure user has valid license, the public static void j(Context context, p pVar) function.

Here is the original version:

public static void j(Context context, p pVar) {
    User user;
    User user2;
    String strS = User.s(context);
    if (strS == null) {
        pVar.a(context.getString(R$string.authentication_failure));
        return;
    }
    if (strS.equals("New User")) {
        pVar.a("New User");
        return;
    }
    User user3 = b;
    if (user3 != null && !user3.disabled.booleanValue()) {
        long jCurrentTimeMillis = System.currentTimeMillis() - b.modified;
        DateUtils.getRelativeTimeSpanString(System.currentTimeMillis() + (b.w() * 1000), System.currentTimeMillis(), 0L, 0);
        DateUtils.getRelativeTimeSpanString(System.currentTimeMillis() + (3600000 - jCurrentTimeMillis), System.currentTimeMillis(), 0L, 0);
        if (b.w() <= 0) {
            gj0.b("API", "Cached user's API session has expired - refreshing session...");
            e(context, null, b.sessionIdentifier, pVar);
            return;
        }
        pVar.b(b);
        if (jCurrentTimeMillis <= 3600000 || context == null || (user2 = b) == null) {
            return;
        }
        e(context, null, user2.sessionIdentifier, null);
        return;
    }
    User userA = User.A(context);
    if (userA == null || userA.disabled.booleanValue() || !userA.H()) {
        e(context, null, null, pVar);
        return;
    }
    b = userA;
    if (userA.w() <= 0) {
        e(context, null, b.sessionIdentifier, pVar);
        return;
    }
    pVar.b(b);
    if (context == null || (user = b) == null) {
        return;
    }
    e(context, null, user.sessionIdentifier, null);
}

pVar.b() is the success callback we'll call while e() is called in case of error. b is the globally stored user we'll have to set. To patch this, we'll simply craft a User with meaningless data, save the user in b, and call the success callback every time.

public static void j(Context context, p pVar) {
    User user = new User();
    user.email = "myemail@google.com";
    user.name = "hello";
    user.given_name = "hello";
    user.sessionExpires = System.currentTimeMillis() + (86400000 * 365);
    user.sessionIdentifier = "";
    b = user;
    pVar.b(user);
}
.method public static j(Landroid/content/Context;Lcom/sonelli/pi0$p;)V
    .locals 8

    # User u = new User();
    new-instance v0, Lcom/sonelli/juicessh/models/User;
    invoke-direct {v0}, Lcom/sonelli/juicessh/models/User;-><init>()V

    # u.email = "myemail@google.com";
    const-string v1, "myemail@google.com"
    iput-object v1, v0, Lcom/sonelli/juicessh/models/User;->email:Ljava/lang/String;

    # u.name = "hello";
    const-string v1, "hello"
    iput-object v1, v0, Lcom/sonelli/juicessh/models/User;->name:Ljava/lang/String;

    # u.given_name = "hello";
    iput-object v1, v0, Lcom/sonelli/juicessh/models/User;->given_name:Ljava/lang/String;

    # long now = System.currentTimeMillis();
    invoke-static {}, Ljava/lang/System;->currentTimeMillis()J
    move-result-wide v2

    # yearMillis = 86400000L * 365L
    const-wide/32 v4, 0x05265c00      # 86400000
    const-wide/16 v6, 0x016d          # 365
    mul-long/2addr v4, v6

    # u.sessionExpires = now + yearMillis;
    add-long/2addr v2, v4
    iput-wide v2, v0, Lcom/sonelli/juicessh/models/User;->sessionExpires:J

    # u.sessionIdentifier = ""
    const-string v1, ""
    iput-object v1, v0, Lcom/sonelli/juicessh/models/User;->sessionIdentifier:Ljava/lang/String;

    # pi0.b = u;
    sput-object v0, Lcom/sonelli/pi0;->b:Lcom/sonelli/juicessh/models/User;

    # pVar.b(b);
    invoke-virtual {p1, v0}, Lcom/sonelli/pi0$p;->b(Lcom/sonelli/juicessh/models/User;)V

    return-void

Recompile

& "C:\Program Files\OpenJDK\jdk-25\bin\java.exe" -jar .\apktool_2.12.1.jar juicessh

Sign the apk

# Create a keystore if needed to self sign the APK
keytool -genkey -v -keystore k.keystore -alias a -keyalg RSA -keysize 2048 -validity 50000

# Sign the APK
jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore k.keystore ./juicessh/dist/juicessh.apk a

Done

You can install this apk, ignore the security warning because it is self signed, and enjoy JuiceSSH with its pro features again.

I don't think the cloud sync will work anymore, but that's a minor inconvenience.