Making the best PDF Unlocker / Decryptor App
Preface
My foray into React native happened approximately a month before of publishing date of this article, I know of very little javascript (non ES6) and almost a newb with React ecosystem. It almost felt like an adventure, after many months developing and improving my libraries and application written in a language(s) I’m comfortable with.
As of writing this, I feel like a pro of javascript. No wonder JS rules across the tech industry, the ease of learning makes it easy for a beginner like me. Even though I’m no stranger to the programming paradigm, it must be said that JS(ES6 variant) is the easiest to write, debug and extend.
With a mixture of learning from a book, Udemy course, and tinkering around with examples, I made a simple app to view semester results of my university. It’s now available on the Google play store.
Problem
You have a pdf file, and it has a password. The aim is to generate a new file from input with password / any other encryption removed.
But why?
In general, it’s a hassle to enter the password every time you need to view it. The default PDF viewer on android does not have a feature to remember passwords.
Often, Financial institutions request bank statements other KYC documents which are often encrypted with a password(for example, eAadhar in India). There are several tools available online with varying downsides. I discussed these issues in the section below.
Competition Research
A search for “pdf unlocker” on google play store returns at least 6 that claim to do the job. But after I tested each one of them(excluding corporate type, discussed in next paragraph) I find their functionality ranging from unusable to badly designed UI to just crash. I don’t want to take the name of the developers here, I have immense respect for my colleagues in the trades irrespective of skill. The problem is quite possibly API and old age(depreciation). Many years after writing this article, my app might as well join them becoming unusable, the trick here is to use a newer SDK for future-proofing. I have a good feeling this is good for at least the next 4-5 Android versions. For this reason, PDFUnlockR
requires a minimum SDK of 24 (API) (Android 7)
Those which work and look nice are the corporate variety. An example is Smallpdf and iLovePDF. Both of these are in a way more than a tool, they have a business model, charging users money if they exceed a certain limit of files. I feel this is a very dubious practice because of two reasons, they offer no value and little convenience, just jump on a computer and there are many programs to decrypt unlimited no. of files with no catch. 2nd reason is even though a guess, they are probably using some open-source library under the hood. The license of said Open source library may even explicitly prohibit profiting from their code.
UI/UX Design
The basic structure of PDFUnlockR
consists of mainly 3 screens.
Home / Queue
Processing
Decrypt(Final Results)
Components used are a combination of vanilla react native and react-native-paper
the best PDF library - QPDF. There’s a problem though…
I’m quite confident QPDF is the best opensource PDF library out there. The emphasis on open-source is important here, during my research I’ve encountered many closed source libraries with java bindings(that would have made my life easier, not having to deal with JNI). The problem is that QPDF is a c++ library and the way to connect it with java is through JNI(Java Native Interface)
Cross Compiling QPDF to multiple CPU architectures.
We first need to compile the library into a shared object(.so
) file to include in our project.
From android/app/gradle.build
we can specify the architectures we can target:
externalNativeBuild {
cmake {
cppFlags "-O2 -frtti -fexceptions -Wall -fstack-protector-all"
abiFilters 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a'
}
}
Here, I’ve chosen to use the library for all 4 ('x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a'
) architecture, cross-compiling is pretty straightforward once you figure out ins and outs of NDK compilers.
QPDF depends on one other library:
I’ve included links to gists of bash scripts to cross-compile these dependencies and also qpdf
itself.
for every change in targetSdkVersion
and compileSdkVersion
, QPDF need to be recompiled again to prevent app from crashing on invoking native code.
compilation of QPDF will fail during cross-compilation due to its inability to confirm the existence of /dev/urandom
. This check must be disabled in configure
#ln 16476 - version - 10.3.2
test "$cross_compiling" = no &&
Running the above bash scripts should produce binary .so
for each architecture.
.
├── arm64-v8a
│ ├── libjpeg.so
│ └── libqpdf.so
├── armeabi-v7a
│ ├── libjpeg.so
│ └── libqpdf.so
├── x86
│ ├── libjpeg.so
│ └── libqpdf.so
└── x86_64
├── libjpeg.so
└── libqpdf.so
JNI + NDK
JNI stands for Java Native Interface. NDK (Native Development Kit) is what allows us to run C++ natively on the android platform.
JNI acts as a bridge between java and NDK/C++.
Stitching it all together
The process of putting all these together starts with the bottom, our pure C++ library which uses qpdf
to decrypt the file.
Update [30/01/2022]
Passing file descriptor to C++ seems to cause problems with targetSdkVersion
>= 30 due to fdsan. I solved this issue by reading the file on java side and passing byteArray
to JNI and further. Refer ContentResolver.
An excerpt is given below:
bool decryptPDF(char* data, std::string filename, std::string out, size_t size, std::string password)
{
QPDF qpdf;
try
{
__android_log_print(ANDROID_LOG_DEBUG, APPNAME, "received inputs: %s %s %zu %s\n", filename.c_str(), out.c_str(), size, password.c_str());
qpdf.processMemoryFile(filename.c_str(), data, size, password.c_str());
QPDFWriter w(qpdf, out.c_str());
w.setPreserveEncryption(false);
w.write();
return true;
}
catch (std::exception& err)
{
__android_log_print(ANDROID_LOG_DEBUG, APPNAME, "Decrypt Error: %s\n", err.what());
throw(&err);
}
}
Next comes the JNI C++, which acts as a glue between java and our code mentioned above:
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_pdfunlockr_QPDFModule_decryptPDF(JNIEnv *env, jclass type, jbyteArray data, jstring filename, jlong pdf_size, jstring password, jstring out) {
const char* input_fname = env->GetStringUTFChars(filename, NULL);
const char* passwd = env->GetStringUTFChars(password, NULL);
const char* output = env->GetStringUTFChars(out, NULL);
jbyte* f_bytes = env->GetByteArrayElements(data, JNI_FALSE);
size_t size = (long)pdf_size;
bool result = false;
try{
result = decryptPDF((char *)f_bytes, std::string(input_fname), std::string(output), size, std::string(passwd));
} catch(std::exception *e){
env->ReleaseByteArrayElements(data, f_bytes, JNI_ABORT);
jclass exp = env->FindClass("java/lang/Exception");
env->ThrowNew(exp, e->what());
};
env->ReleaseByteArrayElements(data, f_bytes, JNI_ABORT);
return jboolean(result);
}
Finally, these native functions can be used in java, and using React native modules, these wrapped methods can be invoked from javascript.
@ReactMethod
public void QPDFdecryptPDF(String file_uri, String filename, String pdf_size, String password, final Promise promise){
Uri uri = Uri.parse(file_uri);
ContentResolver resolver = ctx.getContentResolver();
String output_file;
output_file = ctx.getCacheDir().getAbsolutePath() + "/" + filename;
try {
InputStream is = resolver.openInputStream(uri);
byte[] data = new byte[is.available()];
is.read(data, 0, is.available());
long lsize = Long.parseLong(pdf_size);
boolean out = decryptPDF(data, filename, lsize, password, output_file);
is.close();
promise.resolve(output_file);
}catch(Exception e){
Log.d("PDFUnlockr", e.getMessage());
promise.reject(e.getMessage());
}
}
public static native boolean decryptPDF(byte[] data, String filename, long pdf_size, String password, String output);
Refer to RN Documentation on how to bridge Java <-> Javascript
.
My code is highly inspired at initial setup stage by reime005’s react-native-cpp-code
Exception handling is a MUST if you want to remain sane during the development. Without exception handling, if an error occurs, the app abruptly crashes.
The only way to debug what caused the crash is to look for hints in adb logcat
.this ordeal is comparable to finding a needle in a haystack.
Monetization
I placed just 2 ads.
- Banner Ad in Processing Screen
- Interstitial Ad on final/decrypt stage
There’s a lot of ad placement potential in here, At this point, I don’t feel like adding more ads that might make the app less functional and annoying.
Conclusion
Over and all, I’m still surprised how fast this whole journey has been thanks to JS. Considering how little if anything about making apps before jumping in just a week ago. I still do not know a lot of vanilla java / Kotlin principles and practices. This is not a deficiency of me or a supposedly steep learning curve of Vanilla Android Development, rather the point here is that React Native just WORKS!