Reading and writing files in Android internal storage
Android applications often need a reliable place to save small amounts of private data. A draft message, a downloaded configuration file, a session export, or a JSON document can be stored inside the app’s internal storage without requesting a storage permission.
Internal files are private to your application. Other apps cannot normally access them, and the user does not browse them through the standard Files app. This makes internal storage a useful choice for app-specific content that should be removed when the application is uninstalled.
For Australian users, reliable offline storage can be especially useful. Someone commuting through Sydney or Melbourne may temporarily lose connectivity underground, while users in regional Queensland, Western Australia, or the Northern Territory can face larger coverage gaps. Saving data locally lets an Android app remain useful when a network connection is unavailable.
Choosing the right private directory
Android provides internal storage through the filesDir and cacheDir properties on a Context. The filesDir directory is intended for important app files that should remain available until the app deletes them or the user uninstalls the app. It is appropriate for saved drafts, application settings stored as files, and locally generated reports.
The cacheDir directory is intended for temporary content. Android may remove cached files when storage space is low, so it should not contain the only copy of information that the user expects to keep. A cached image, temporary PDF, or intermediate download is a better fit than a user’s saved note.
val permanentFile = File(filesDir, "profile.json")
val temporaryFile = File(cacheDir, "preview.tmp")
Internal storage is separate from shared device storage. You do not need READ_EXTERNAL_STORAGE or WRITE_EXTERNAL_STORAGE to use these directories. Modern Android versions also apply scoped storage rules to shared storage, making private internal files the simplest option when content does not need to be opened by other applications.
Writing text and binary content safely
For a small text file, openFileOutput() provides a straightforward API. MODE_PRIVATE creates the file for the application and replaces existing content. Use MODE_APPEND when new text should be added to the end of the file.
fun saveNote(context: Context, note: String) {
context.openFileOutput("daily_note.txt", Context.MODE_PRIVATE).use { output ->
output.write(note.toByteArray(Charsets.UTF_8))
}
}
Reading the same file can be done with openFileInput(). The bufferedReader() helper decodes the bytes as text and makes the result easy to handle. File operations can throw exceptions, such as FileNotFoundException or IOException, so production code should handle failures and display a useful message.
fun loadNote(context: Context): String? {
return try {
context.openFileInput("daily_note.txt")
.bufferedReader(Charsets.UTF_8)
.use { it.readText() }
} catch (error: IOException) {
null
}
}
For binary data, use FileInputStream and FileOutputStream, or Kotlin’s readBytes() and writeBytes() functions. Avoid loading very large files into memory. A long video, database backup, or high-resolution image should be copied through buffered streams instead.
| Storage option | Suitable content | Lifetime and access |
|---|---|---|
filesDir |
Saved notes, JSON, exports, app documents | Private and persistent until deletion or uninstall |
cacheDir |
Temporary images, previews, download fragments | Private but removable by Android |
| Shared storage | Photos, documents shared with other apps | User-visible and governed by MediaStore or the Storage Access Framework |
| SQLite or Room | Structured records and searchable data | Private database with queries and transactions |
| DataStore or SharedPreferences | Small preferences and settings | Private key-value data |
Making file operations reliable
A file name should be controlled by the application rather than copied directly from user input. Names containing path separators can create invalid paths or expose unsafe behaviour. For user-created documents, generate a safe name or use a fixed directory and validate the input.
Writing directly to an existing file can leave incomplete content if the application stops during the operation. A safer pattern is to write a temporary file and then replace the original after the write succeeds.
fun saveJsonSafely(context: Context, json: String) {
val target = File(context.filesDir, "settings.json")
val temporary = File(context.filesDir, "settings.json.tmp")
temporary.writeText(json, Charsets.UTF_8)
if (!temporary.renameTo(target)) {
temporary.delete()
error("Unable to replace settings file")
}
}
For important documents, consider using FileOutputStream.fd.sync() before closing the stream, although this can add cost. The operation should run away from the main thread. Kotlin coroutines with Dispatchers.IO are a practical choice because disk access can pause for a noticeable time on lower-cost devices.
Connecting files with Android components
An Activity or ViewModel can call a repository class that owns file access. Keeping storage code outside the user interface makes it easier to test and prevents lifecycle code from becoming crowded. A repository can expose functions such as saveDraft(), readDraft(), and deleteDraft().
class NoteRepository(private val context: Context) {
private val fileName = "note.txt"
suspend fun save(text: String) = withContext(Dispatchers.IO) {
context.openFileOutput(fileName, Context.MODE_PRIVATE).use {
it.write(text.toByteArray())
}
}
suspend fun read(): String? = withContext(Dispatchers.IO) {
try {
context.openFileInput(fileName).bufferedReader().use { it.readText() }
} catch (error: FileNotFoundException) {
null
}
}
}
A ViewModel can call this repository and expose the result through StateFlow or LiveData. This approach prevents a screen rotation from starting duplicate reads and gives the interface a clear loading or error state. A simple loading message is valuable when a user is opening a large offline document during a train trip in Melbourne or while travelling between regional towns.
Use JSON for small structured documents, but avoid treating a file as a database. If the app needs filtering, sorting, relationships, or frequent updates, Room over SQLite is more suitable. A file can still be used for import and export, while Room manages the live application data.
Protecting private application data
Internal storage is private by default, but privacy is not the same as encryption. A rooted device, a debugging environment, a compromised phone, or an incorrectly configured backup process may expose data. Do not save passwords, payment details, or authentication tokens as plain text.
For sensitive values, use Android Keystore-backed solutions or Jetpack Security’s encrypted preferences and encrypted file APIs. Keep encryption keys out of the file itself. Australian developers should also consider obligations under the Privacy Act 1988 and the Australian Privacy Principles when an app collects personal information, even if that information is stored locally.
Android backup behaviour should be checked as part of release testing. Some app data may be included in cloud backups depending on the device version and manifest configuration. If a file contains information that must not leave the device, define suitable backup rules and explain the data handling in the app’s privacy documentation.
Test file behaviour on an emulator and a physical device. Check an empty installation, an upgrade, low available storage, a failed write, and an uninstall. Test offline use in conditions that resemble real Australian travel, such as an underground Sydney commute or limited regional coverage. These checks reveal whether the app restores, replaces, and deletes private files as intended.