You Must Be 64-Bit to Ride This Ferry
Reverse engineering an updated NY Waterway app for the Pixel 7
TLDR: If you have a newer Android device that won’t let you install NY Waterway, you can download my modified version of the application. You should always be careful installing random applications, especially from sources other than the official Play Store — like this Medium post by a random guy you’ve never heard of. If you want to be extra cautious, you can read ahead to see how the APK was modified (and even repeat the steps yourself if you want).
In 2019, Google made 64-bit support required for all new and updated applications in the Play Store. Starting in August 2021, applications that do not support the 64-bit architecture became unavailable in the Play Store for 64-bit capable devices. Notably, the new Pixel 7 and Pixel 7 Pro do not support installing 32-bit only applications at all.
For New Yorkers riding the Hudson River ferry, this is quite inconvenient because the application that provides electronic tickets on your phone, NY Waterway, is really old. It was last published in June 2018, and contains native libraries for 32-bit architectures only… Therefore, for users of the new Pixel devices, no electronic tickets on the Hudson River ferry for you!
I switched to iPhone many years ago now, but back when I was an Android user, I used to hack around with the OS and applications a lot — installing custom ROMs and decompiling applications. A close friend of mine got the new Pixel 7 Pro and takes the Hudson River ferry all the time, so he jokingly prodded me to fix this app for him. Here we go!
Peering into the Application
Let’s start by inspecting the NY Waterway application to identify the parts that are 32-bit only, which are preventing it from being installed. Using apktool
, we can extract the Android application and inspect its code.
$ apktool d ./NYWaterway.apk
I: Using Apktool 2.6.1 on NYWaterway.apk
I: Loading resource table...
I: Decoding AndroidManifest.xml with resources...
I: Loading resource table from file: /Users/joeywatts/Library/apktool/framework/1.apk
I: Regular manifest package...
I: Decoding file-resources...
I: Decoding values */* XMLs...
I: Baksmaling classes.dex...
I: Copying assets and libs...
I: Copying unknown files...
I: Copying original files...
$ cd ./NYWaterway
$ ls -l
total 72
-rw-r--r-- 1 joeywatts staff 8797 Nov 21 18:37 AndroidManifest.xml
-rw-r--r-- 1 joeywatts staff 21382 Nov 21 18:37 apktool.yml
drwxr-xr-x 14 joeywatts staff 448 Nov 21 18:37 assets
drwxr-xr-x 5 joeywatts staff 160 Nov 21 18:37 lib
drwxr-xr-x 4 joeywatts staff 128 Nov 21 18:37 original
drwxr-xr-x 178 joeywatts staff 5696 Nov 21 18:37 res
drwxr-xr-x 10 joeywatts staff 320 Nov 21 18:37 smali
drwxr-xr-x 10 joeywatts staff 320 Nov 21 18:37 unknown
apktool
will output a new directory that contains the application bytecode decompiled from binary into a human-readable text-based format called Smali, bundled resources (such as images), native libraries, and application configuration. Smali might look scary, but it’s more approachable than you might think — more on that later… For now, let’s focus on fixing the 64-bit compatibility. To do that, we need to see what native libraries are being used.
64-bit Compatibility and Native Libraries
Android applications are typically written in Java or Kotlin, both languages that target the Java Virtual Machine, which is a high-level abstraction that generally shields you from concerns about platform-specific compatibility. However, you can use the Java Native Interface (JNI) to call out to native, platform-specific code (usually compiled from lower-level languages like C or C++). If we look at the libs
directory, we can see the native libraries included in the NY Waterway app.
$ ls -lR lib/*
lib/armeabi:
total 8352
-rw-r--r-- 1 joeywatts staff 177900 Nov 21 18:37 libdatabase_sqlcipher.so
-rw-r--r-- 1 joeywatts staff 1369284 Nov 21 18:37 libsqlcipher.so
-rw-r--r-- 1 joeywatts staff 2314540 Nov 21 18:37 libsqlcipher_android.so
-rw-r--r-- 1 joeywatts staff 402604 Nov 21 18:37 libstlport_shared.so
lib/armeabi-v7a:
total 2552
-rw-r--r-- 1 joeywatts staff 1303788 Nov 21 18:37 libsqlcipher.so
lib/x86:
total 14616
-rw-r--r-- 1 joeywatts staff 1476500 Nov 21 18:37 libdatabase_sqlcipher.so
-rw-r--r-- 1 joeywatts staff 2246448 Nov 21 18:37 libsqlcipher.so
-rw-r--r-- 1 joeywatts staff 3294132 Nov 21 18:37 libsqlcipher_android.so
-rw-r--r-- 1 joeywatts staff 455740 Nov 21 18:37 libstlport_shared.so
We can see three directories under lib
, each corresponding to different platforms: x86
, armeabi
, armeabi-v7a
. All three of these platforms are 32-bit. The vast majority of Android devices (basically all phones) use the ARM architecture. “armeabi” is the legacy ARM architecture (no longer supported by Android). ARM v7 (“armeabi-v7a”) is the 32-bit ARM architecture. For 64-bit ARM support, we’d expect there to be an arm64-v8a
folder.
Another observation here is that armeabi
and x86
have four libraries while armeabi-v7a
only has one. For a library to be loaded by the Android app, it would have to call into java.lang.System.loadLibrary
or java.lang.Runtime.loadLibrary
. Searching the Smali code for “loadLibrary” reveals only one place where it’s loading native libraries.
$ grep -r loadLibrary smali/
smali//net/sqlcipher/database/SQLiteDatabase.smali: invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
$ grep loadLibrary -A 2 -B 3 smali/net/sqlcipher/database/SQLiteDatabase.smali
:try_start_0
const-string v0, "sqlcipher"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
:try_end_0
.catchall {:try_start_0 .. :try_end_0} :catchall_0
The only library loaded directly by the application is “sqlcipher” (libsqlcipher.so
). Other library files listed for some architectures are either unused or just transitive dependencies of libsqlcipher.so
.
We need a 64-bit ARM build of libsqlcipher.so
in lib/arm64-v8a
to make the application compatible with the new Pixel devices. Conveniently, SQLCipher is an open source library. Looking at the high-level glue code for interacting with the native sqlcipher library, we can see the version of the library that was used.
$ grep -ri version smali/net/sqlcipher
smali/net/sqlcipher/database/SQLiteDatabase.smali:.field public static final SQLCIPHER_ANDROID_VERSION:Ljava/lang/String; = "3.5.4"
After doing some quick digging around on the open source repo, I can see that true 64-bit support landed with v3.5.5 (one patch version newer than what is used in NY Waterway). Let’s try to upgrade!
Upgrading SQLCipher to v3.5.5
The process to upgrade will involve replacing the SQLCipher Smali code and native libraries with the code from the newer version. This would cause issues if the public API surface of SQLCipher changed significantly (for example, if a public function used by NY Waterway either changed signature or was removed, then replacing it with the newer version would cause issues). Doing a quick scan of the changes from v3.5.4 to v3.5.5, it doesn’t seem like an issue that will appear here. I downloaded the AAR file for SQLCipher v3.5.5 and then used unzip
to extract it.
$ mkdir ../sqlcipher && cd ../sqlcipher
$ unzip ~/Downloads/android-database-sqlcipher-3.5.5.aar
Archive: /Users/joeywatts/Downloads/android-database-sqlcipher-3.5.5.aar
inflating: AndroidManifest.xml
creating: res/
inflating: classes.jar
creating: jni/
creating: jni/arm64-v8a/
creating: jni/armeabi/
creating: jni/armeabi-v7a/
creating: jni/x86/
creating: jni/x86_64/
inflating: jni/arm64-v8a/libsqlcipher.so
inflating: jni/armeabi/libsqlcipher.so
inflating: jni/armeabi-v7a/libsqlcipher.so
inflating: jni/x86/libsqlcipher.so
inflating: jni/x86_64/libsqlcipher.so
After extracting it, we see under the jni
directory are the native libraries. It also outputs a classes.jar
file containing all the Java class files that call out to the native library. These aren’t Smali files, so we’ll need to convert this code in order to get it into a format that apktool
understands.
The Android SDK provides a command line tool called d8
that can compile a jar
file to Android byte code (classes.dex
file). Then there’s another tool called baksmali
that can decompile dex
files into smali
. Combining the steps together:
$ export ANDROID_HOME=/Users/joeywatts/Library/Android/sdk
$ $ANDROID_HOME/build-tools/33.0.0/d8 classes.jar \
--lib $ANDROID_HOME/platforms/android-31/android.jar
$ java -jar ../baksmali.jar dis ./classes.dex
This produces an out
directory containing the Smali code for the library. Therefore, we can simply replace smali/net/sqlcipher
with out/net/sqlcipher
and the lib
directory with jni
.
$ rm -r ../NYWaterway/smali/net/sqlcipher ../NYWaterway/lib
$ mv out/net/sqlcipher ../NYWaterway/smali/net/sqlcipher
$ mv jni ../NYWaterway/lib
Building and Running the Modified App
Now, we can rebuild the application and sign it, so it can be installed on a device!
$ cd ../NYWaterway
$ apktool b .
$ keytool -genkey -v -keystore my-release-key.keystore -alias alias_name \
-keyalg RSA -keysize 2048 -validity 10000
$ $ANDROID_HOME/build-tools/33.0.0/apksigner sign \
--ks my-release-key.keystore ./dist/NYWaterway.apk
After installing ./dist/NYWaterway.apk
, it shows this screen!

Increasing the Target SDK Version
To get rid of this popup indicating that the application was built for an older version of Android, we need to increase the target SDK version in apktool.yml
. Applications that target SDK version <31 are no longer accepted in the Play Store, so I chose to increase it to that.
Targeting a newer version of the Android SDK may require code changes because deprecated APIs become unavailable in newer SDK versions. NY Waterway requires several changes to target SDK v31.
Safer Component Exporting
If your app targets Android 12 or higher and contains activities, services, or broadcast receivers that use intent filters, you must explicitly declare the
android:exported
attribute for these app components.
There are a couple of activities and one receiver that have <intent-filter>
s and require an android:exported="true"
attribute to be added in AndroidManifest.xml
.
Pending Intents Mutability
If your app targets Android 12, you must specify the mutability of each
PendingIntent
object that your app creates. This additional requirement improves your app's security.
This one is trickier, because it requires us to change the actual code (as opposed to project configuration or copying an upgraded version of the library).
Any time a PendingIntent
object is created, it needs to explicitly specify FLAG_MUTABLE
or FLAG_IMMUTABLE
. In prior SDK versions, FLAG_MUTABLE
was the default if neither flag was specified. PendingIntent
objects are created by a set of static methods on the class: getActivity
, getActivities
, getBroadcast
, or getService
. We can start by searching for invocations of those functions.
$ grep -r -E "PendingIntent;->(getActivity|getActivities|getBroadcast|getService)" smali
smali/android/support/v4/f/a/ac.smali: invoke-static {p1, v2, v0, v2}, Landroid/app/PendingIntent;->getBroadcast(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
smali/com/google/firebase/iid/r.smali: invoke-static {p0, p1, v0, p4}, Landroid/app/PendingIntent;->getBroadcast(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
smali/com/google/firebase/iid/m.smali: invoke-static {p0, v2, v0, v3}, Landroid/app/PendingIntent;->getBroadcast(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
smali/com/google/firebase/messaging/c.smali: invoke-static {v0, v2, v1, v3}, Landroid/app/PendingIntent;->getActivity(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
smali/com/google/android/gms/common/m.smali: invoke-static {p1, p3, v0, v1}, Landroid/app/PendingIntent;->getActivity(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
smali/com/google/android/gms/common/api/GoogleApiActivity.smali: invoke-static {p0, v0, v1, v2}, Landroid/app/PendingIntent;->getActivity(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
smali/com/google/android/gms/c/cbx.smali: invoke-static {v1, v2, v0, v3}, Landroid/app/PendingIntent;->getActivity(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
smali/com/google/android/gms/c/cbx.smali: invoke-static {v2, v7, v1, v7}, Landroid/app/PendingIntent;->getBroadcast(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
smali/com/google/android/gms/c/v.smali: invoke-static {v0, v1, v2, v3}, Landroid/app/PendingIntent;->getActivity(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
smali/com/google/android/gms/c/bj.smali: invoke-static {v1, p2, v0, v2}, Landroid/app/PendingIntent;->getActivity(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
smali/com/google/android/gms/c/byd.smali: invoke-static {v1, v4, v0, v4}, Landroid/app/PendingIntent;->getBroadcast(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
smali/com/google/android/gms/c/mr.smali: invoke-static {v1, v3, v0, v3}, Landroid/app/PendingIntent;->getBroadcast(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
Quite a few! Luckily, most of them are fairly simple changes. We just need to understand a little bit about the byte code first.
Understanding Smali
The invoke-static
byte code instruction takes a list of registers to be passed as parameters into the static function. The static function’s symbol looks like Landroid/app/PendingIntent;->getBroadcast(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
which is a direct translation from the fully qualified class name and the signature of the function. It starts with the class name Landroid/app/PendingIntent;
(or android.app.PendingIntent
in normal Java syntax). Then the function’s name (->getBroadcast
) along with the parameters and return type. Landroid/content/Context;ILandroid/content/Intent;I
are the parameters, which can be split into four parameters: Landroid/content/Context;
(android.content.Context
), I
(int
), Landroid/content/Intent;
(android.content.Intent
), and I
(int
). Finally, after the closing parenthesis is the return type: Landroid/app/PendingIntent;
.
Therefore, invoke-static {v1, v2, v3, v4}
of the above function would pass v1
as the Context
, v2
as the first int
, v3
as the Intent
, and v4
as the int
. For these PendingIntent
APIs, the flags
are always the last parameter (int
) so we just need to ensure that the value always has either FLAG_MUTABLE
or FLAG_IMMUTABLE
set. The Android SDK documentation reveals that the value of FLAG_MUTABLE
is 0x02000000
and FLAG_IMMUTABLE
is 0x04000000
. In most cases, the last parameter is specified as a local variable register (v#
) that was initialized with a constant value (such as const/high16 v3, 0x8000000
or const/4 v4, 0x0
). In these cases, we can trivially check whether FLAG_MUTABLE
or FLAG_IMMUTABLE
is set and update the constant if it’s not.
- const/high16 v3, 0x8000000
+ const/high16 v3, 0xA000000
invoke-static {v1, v2, v0, v3}, Landroid/app/PendingIntent;->getActivity(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
# you may need to change from const/4 to const/high16 to specify the flag
# const/4 is a loading a signed 4-bit integer (seen used to load 0x0).
# const/high16 loads the high 16-bits from a value (the low 16-bits must be 0)
- const/4 v4, 0x0
+ const/high16 v4, 0x2000000
There was one case (in com/google/firebase/iid/r.smali
) where the flags
were passed in as a parameter (p#
registers).
.method private static a(Landroid/content/Context;ILjava/lang/String;Landroid/content/Intent;I)Landroid/app/PendingIntent;
.locals 2
new-instance v0, Landroid/content/Intent;
const-class v1, Lcom/google/firebase/iid/FirebaseInstanceIdInternalReceiver;
invoke-direct {v0, p0, v1}, Landroid/content/Intent;-><init>(Landroid/content/Context;Ljava/lang/Class;)V
invoke-virtual {v0, p2}, Landroid/content/Intent;->setAction(Ljava/lang/String;)Landroid/content/Intent;
const-string v1, "wrapped_intent"
invoke-virtual {v0, v1, p3}, Landroid/content/Intent;->putExtra(Ljava/lang/String;Landroid/os/Parcelable;)Landroid/content/Intent;
invoke-static {p0, p1, v0, p4}, Landroid/app/PendingIntent;->getBroadcast(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;
move-result-object v0
return-object v0
.end method
Updating p4
to set the FLAG_MUTABLE
bit if necessary is easier than following all the references to this function up to where the flags
are specified. To do that, we need to write some byte code by hand! The equivalent Java-like code would be something like this:
if (p4 & (FLAG_IMMUTABLE | FLAG_MUTABLE) == 0) {
p4 |= FLAG_MUTABLE;
}
FLAG_IMMUTABLE | FLAG_MUTABLE
is the constant 0x6000000
, which we can load into a register with the const/high16
instruction. We can use the and-int
instruction to bitwise AND it with p4
. if-nez
allows you to jump to a label if a register does not equal zero. Finally, or-int
lets us do a bitwise OR of two registers. Google has documentation on the Dalvik byte code that is useful for discovering the instructions and their syntax. Putting all of this together, we get the following code that can be inserted before the call to getBroadcast
.
const/high16 v3, 0x6000000 # v3 = FLAG_IMMUTABLE | FLAG_MUTABLE
and-int v2, p4, v3 # v2 = p4 & v3
if-nez v2, :cond_0 # if (v2 != 0) { goto :cond_0; }
const/high16 v3, 0x2000000 # v3 = FLAG_MUTABLE
or-int p4, p4, v3 # p4 = p4 | v3
:cond_0
Finally, the .locals 2
directive at the top of the function indicates that two local variable registers should be allocated for this function (v0
and v1
). Because we used two more (v2
and v3
) in the above code, we need to change this to .locals 4
.
File system permission changes
Private files’ file permissions should no longer be relaxed by the owner, and an attempt to do so using
MODE_WORLD_READABLE
and/orMODE_WORLD_WRITEABLE
, will trigger aSecurityException
.
There was some SharedPreferences
API usage that was using MODE_WORLD_READABLE
in com/google/android/gms/ads/identifier/AdvertisingIdClient.smali
. This was very simple to fix, since it was a matter from switching to MODE_WORLD_READABLE
(0x1
) to MODE_PRIVATE
(0x0
).
--- a/smali/com/google/android/gms/ads/identifier/AdvertisingIdClient.smali
+++ b/smali/com/google/android/gms/ads/identifier/AdvertisingIdClient.smali
@@ -93,7 +93,7 @@
const-string v4, "google_ads_flags"
- const/4 v5, 0x1
+ const/4 v5, 0x0
invoke-virtual {v2, v4, v5}, Landroid/content/Context;->getSharedPreferences(Ljava/lang/String;I)Landroid/content/SharedPreferences;
Apache HTTP client deprecation
NY Waterway was using the Android version of the Apache HTTP client, but the fix for this is pretty simple — just another change to the AndroidManifest.xml
.
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 1490d73..39ccbf3 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -16,6 +16,7 @@
<permission android:name="co.bytemark.nywaterway.permission.C2D_MESSAGE" android:protectionLevel="signature"/>
<uses-permission android:name="co.bytemark.nywaterway.permission.C2D_MESSAGE"/>
<application android:allowBackup="false" android:icon="@drawable/icon" android:label="@string/app_name" android:name="co.bytemark.nywaterway2.core.NYWWApp" android:theme="@style/AppTheme">
+ <uses-library android:name="org.apache.http.legacy" android:required="false" />
<meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version"/>
<receiver android:exported="false" android:label="NetworkConnection" android:name="co.bytemark.android.sdk.BytemarkSDK$ConnectionChangeReceiver">
<intent-filter>
Network TLS enabled by default
If your app targets Android 9 or higher, the
isCleartextTrafficPermitted()
method returnsfalse
by default. If your app needs to enable cleartext for specific domains, you must explicitly setcleartextTrafficPermitted
totrue
for those domains in your app's Network Security Configuration.
Network requests were failing due to this new security feature. The simplest way to make the application compatible was just another change to the AndroidManifest.xml
to add the android:usesCleartextTraffic="true"
attribute.
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 39ccbf3..69b4aa7 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -15,7 +15,7 @@
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE"/>
<permission android:name="co.bytemark.nywaterway.permission.C2D_MESSAGE" android:protectionLevel="signature"/>
<uses-permission android:name="co.bytemark.nywaterway.permission.C2D_MESSAGE"/>
- <application android:allowBackup="false" android:icon="@drawable/icon" android:label="@string/app_name" android:name="co.bytemark.nywaterway2.core.NYWWApp" android:theme="@style/AppTheme">
+ <application android:allowBackup="false" android:icon="@drawable/icon" android:label="@string/app_name" android:name="co.bytemark.nywaterway2.core.NYWWApp" android:theme="@style/AppTheme" android:usesCleartextTraffic="true">
<uses-library android:name="org.apache.http.legacy" android:required="false" />
<meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version"/>
<receiver android:exported="false" android:label="NetworkConnection" android:name="co.bytemark.android.sdk.BytemarkSDK$ConnectionChangeReceiver">
Conclusion
After making all the above changes, the application successfully runs with no nagging popup that it was built for an older version of Android!
Somewhat unexpectedly, making it work with the newer target SDK version was a lot more involved than actually fixing the 64-bit issue, but at the end of the day, everything is just code and code is nothing to be scared of…