JuiceSSH - Give me back my pro features
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 methodsmali/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 methodsmali/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-voidRecompile
& "C:\Program Files\OpenJDK\jdk-25\bin\java.exe" -jar .\apktool_2.12.1.jar juicesshSign 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 aDone
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.