skip to Main Content

How to Safely Store and Open User Files Locally in Android

March 15, 20267 minute read

  

Have you ever had to take on a task that caused the previous developer to burn out and leave the company? Well, I happened to run into exactly that kind of task. The task of ensuring secure local storage for files that a user uploads into the application, for example while communicating with technical support in a chat. I will cover all the details and explain everything thoroughly in this article.

Getting a file from the user

There are different ways to let a user choose a document for upload, for example by creating an intent through Intent.createChooser with the action we need, Intent.ACTION_OPEN_DOCUMENT, and other parameters required for your specific case. The system will temporarily grant us access to the file via a URI. Example of such a URI:

content:/com.android.providers.downloads.documents/document/msf:1000000019

Unlike a regular file path in the system, for example /data/user/0/com.mobile.android/cache/cache_files/Test.pdf, where we can simply wrap the path in File, create a Body through File.asRequestBody, and put it into MultipartBody.Part to send it to the server. To work with a URI using the content scheme, you need to create a custom RequestBody whose BufferedSink will receive the InputStream of our file during the request, obtained from the URI through contentResolver:

fun createRequestBodyFromUri(uri: Uri): RequestBody {
return object : RequestBody() {

override fun contentType(): MediaType = uriInfoProvider.getMimeType(uri)

override fun contentLength(): Long = uriInfoProvider.getFileSize(uri)

override fun writeTo(sink: BufferedSink) {
val input = context.contentResolver.openInputStream(uri)
?: throw IOException("Cannot open input stream for uri=$uri")

input.use { sink.writeAll(it.source()) }
}
}
}

In my experience, I periodically see suboptimal solutions from developers where temporary files are created without any real need, but even worse, those same temporary files are even more often never cleaned up at all. The approach above removes that need, unless otherwise specified in the business requirements for the feature.

Next, we need to deal with local data storage.

Saving in encrypted format

The encryption library used here is androidx.security.crypto and its EncryptedFile. A small example of creating an encrypted file:

val file = File(context.getFilesDir(), "sensitive_data")

val encryptedFile = EncryptedFile.Builder(
file,
context,
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()

After that, we can use encryptedFile to write to it or read from it.

// write to the encrypted file
val encryptedOutputStream = encryptedFile.openFileOutput()

// read the encrypted file
val encryptedInputStream = encryptedFile.openFileInput()

An important feature is the need to delete the file before writing encrypted data. It is not possible to write something into an existing file.

If we are going to use the decrypted file immediately, for example to play it in MediaPlayer, there will be no difficulties, but what if our encrypted file must be opened by a third-party system application for viewing documents?

How a third-party app can safely read your encrypted file

If your file must be opened by third-party applications, then you will need to implement a secure mechanism for providing your encrypted data to the third-party consumer app. To do this, you can implement a custom ContentProvider that will be able to handle your new URI and temporarily grant the third-party application access to the file through that URI.

Now to the implementation. First, declare the ContentProvider in the Manifest:

<provider
android:name=".provider.FileDecryptionContentProvider"
android:authorities="${applicationId}.filedecryptionrpovider"
android:exported="false"
android:grantUriPermissions="true" />

The grantUriPermissions parameter is required so that the third-party application can gain access to your data only if the Intent initiating the file opening had the necessary permissions, which you must explicitly pass. Next, declare our ContentProvider:

class FileDecryptionContentProvider : ContentProvider() {

// ...

override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
if (mode != "r") throw SecurityException("Only read mode supported")
val file = resolveFileFromUri(uri)
if (!file.exists()) throw FileNotFoundException("File not found: $file")
val storageMode = detectStorageMode(file)
return openFile(file, storageMode)
}

// ...
}

If you are going to implement a similar encryption algorithm, you may want to use ContentProvider not only for sensitive encrypted data, but also for regular data. In that case, you will need a mechanism to determine whether the URI being opened is encrypted or can simply be opened directly. For security reasons, I will not reveal my implementation, but I think you will be able to come up with a solution for your specific project, considering that you will be the one forming the URI that arrives in openFile.

But what exactly is ParcelFileDescriptor and how do you create it for reading a file?

Creating ParcelFileDescriptor

If you want to hand over an unencrypted file for reading by a third-party process, a call to ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) is enough. For an encrypted file, the situation is more sophisticated, because after decryption we get an InputStream, which cannot simply be written into ParcelFileDescriptor.createPipe() and returned from the openFile method — that was my first attempt, and it did not work:

val pipe = ParcelFileDescriptor.createPipe()
val readSide = pipe[0]val writeSide = pipe[1]
ioExecutor.execute {
ParcelFileDescriptor.AutoCloseOutputStream(writeSide).use { out ->
encryptingManager.openInputStream(encryptedFile).use { input ->
input.copyTo(out)
}
}
}

return readSide

At this point I was even a little upset, because everything had been going so smoothly, and I really did not want to repeat the fate of the previous developer, nor redo the whole solution. I did not give up and found my solution in the overloaded ParcelFileDescriptor.open method, which allows you to pass a callback that is triggered as soon as the third-party process closes the descriptor. The logic started to look something like this:

val tempFile = File.createTempFile(
"temp_$encryptedFileName",
encryptedFileExtension,
parentDir,
)
encryptingManager.openInputStream(encryptedFile).use { input ->
FileOutputStream(tempFile).use { output ->
input.copyTo(output)
}
}
ParcelFileDescriptor.open(
tempFile,
ParcelFileDescriptor.MODE_READ_ONLY,
Handler(Looper.getMainLooper())
) {
tempFile.delete()
}

After the logic for providing the file to an external application is implemented, let us look at how to create and open a URI that will invoke our FileDecryptionContentProvider.

Creating a correct URI for FileDecryptionContentProvider

To create a correct URI, you can use the builder:

Uri.Builder()
.scheme("content")
.authority("${context.packageName}.filedecryptionrpovider")
// here you can specify all params to retrieve file later
.build()

Next, create an Intent with the required permissions and the URI formed earlier (it determines the application that will read our file):

Intent(Intent.ACTION_VIEW).apply {
setDataAndTypeAndNormalize(uri, mimeType)
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
}

Create a chooser Intent (so that the user is always shown the application selection dialog, although this is not required, but safer) and launch the activity.

val chooserIntent = Intent.createChooser(targetIntent, chooserTitle).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(chooserIntent)

After that, the user will be able to choose an application to open the file and view its contents.

Reflection on encryption

The need to store sensitive user data locally is generally a controversial decision. After all, data that is truly PII (Personally Identifiable Information) should perhaps always be loaded from the server, without allowing caching. And if caching is still needed for one reason or another, then maybe the data should not be encrypted at all, since it is stored inside the application’s closed sandbox, and if device integrity is compromised (through the use of root), EncryptedFile itself may no longer be so reliable.

There are plenty of arguments for and against in this matter, however regardless of them, sometimes the decision may be made by the regulatory body of your organization or country, and require encryption even if, as a developer, I do not fully understand why.

What do you think about this, do you always store local data in encrypted format?


How to Safely Store and Open User Files Locally in Android was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.

 

Web Developer, Web Design, Web Builder, Project Manager, Business Analyst, .Net Developer

No Comments

This Post Has 0 Comments

Leave a Reply

Back To Top