Table of Contents
Introduction
File handling in Android lets your app create, read, update, and delete files stored on the device. In this chapter you focus on using files with the internal storage APIs that are commonly used in beginner Android apps, without going into database or network storage. You learn how to work with text and binary data, how to structure your file operations, and how to avoid common mistakes such as blocking the main thread.
Internal Storage File Locations
When you work with files in Android, one of the most important ideas is the app specific internal storage. Every app gets its own directory inside the device storage. Other apps cannot read or write this directory. When the user uninstalls your app, this directory and all the files inside it are automatically deleted.
Inside an Activity or Context, Android provides helper methods that return File objects pointing to your app internal directories. The most common ones for file handling are:
filesDir returns the main internal directory for your app. This is where you typically store your own files.
cacheDir returns a directory for temporary files that your app can delete at any time. The system may also delete files here when it needs more space.
You will often see code like:
val directory: File = filesDir
val path = File(directory, "notes.txt")
This creates a File object that represents a file named notes.txt in your app internal files directory. At this point the file may or may not actually exist on disk. It is just a reference.
If you want to organize data, you can create subdirectories under filesDir with File(directory, "subfolder") and call mkdir() or mkdirs() on that File instance.
Creating and Writing Files
In Kotlin, you usually work with file contents through streams or convenience extension functions. For simple text data, the easiest approach is to use the extension functions on File such as writeText and appendText.
For example, to create and write a text file in internal storage you can do:
val file = File(filesDir, "user_profile.txt")
file.writeText("Name: Alice\nAge: 30")
If the file does not exist, writeText creates it. If it exists, writeText overwrites the entire content. If you want to add to the existing content instead of replacing it, use appendText:
val logFile = File(filesDir, "events.log")
logFile.appendText("App started at: ${System.currentTimeMillis()}\n")
For more explicit control you can use openFileOutput, which is a method on Context. It returns a FileOutputStream that you can write bytes to. This is useful when you work with binary data or when you want to control the write mode through flags.
val filename = "image_cache.bin"
openFileOutput(filename, Context.MODE_PRIVATE).use { output ->
val data: ByteArray = getBinaryData() // Your own function
output.write(data)
}
The use block automatically closes the stream when the block finishes, even if an exception occurs.
The second parameter Context.MODE_PRIVATE is the default write mode for internal storage files. It means the file can only be accessed by your app. You can also use Context.MODE_APPEND to add to an existing file instead of replacing it when you call openFileOutput.
When you work with files in Android, always write them in a background thread or with coroutines. Never perform long or large file operations directly on the main thread, because this can freeze the user interface and cause ANR (Application Not Responding) errors.
Reading Files
Reading files from internal storage is symmetric to writing. For simple text files, the File class provides readText and readLines extension functions.
To read a whole text file as a String:
val file = File(filesDir, "user_profile.txt")
if (file.exists()) {
val content: String = file.readText()
println(content)
}
You should always check file.exists() if there is a chance the file is not created yet. Trying to read a non existent file with readText will throw an exception.
If you want to process a file line by line, readLines returns a List<String> with one entry per line:
val logFile = File(filesDir, "events.log")
if (logFile.exists()) {
val lines: List<String> = logFile.readLines()
for (line in lines) {
println(line)
}
}
To read binary data, use openFileInput to get a FileInputStream:
val filename = "image_cache.bin"
openFileInput(filename).use { input ->
val bytes = input.readBytes()
// Use your binary data
}
If the file you pass to openFileInput does not exist, Android will throw a FileNotFoundException. You can catch this exception or check for the file with File(filesDir, filename).exists() before you call openFileInput.
Deleting and Managing Files
To manage files in internal storage, Android gives you two main approaches. You can work directly with the File class, or you can use helper methods on Context. The File approach is more flexible and is usually preferred for anything beyond trivial operations.
To delete a file using File:
val file = File(filesDir, "user_profile.txt")
val deleted: Boolean = file.delete()
if (deleted) {
println("File removed")
}
If you want to remove a file using the context, you can call deleteFile("user_profile.txt"). This method only requires the file name relative to filesDir.
To list files inside your app internal directory, you can use either filesDir.listFiles() or fileList() on Context. The fileList() method returns an array of file names, not File objects.
val allFiles: Array<File>? = filesDir.listFiles()
allFiles?.forEach { f ->
println("Found file: ${f.name} size=${f.length()}")
}
The method length() on File returns the size in bytes as a Long. This is useful when you want to limit your file size or show file information to the user.
When you work with subdirectories, be careful that delete() only removes files or empty directories. If you want to remove a directory with content, you must recursively delete each child file first.
Handling Errors and Exceptions
File operations can fail for many reasons. For example, the device can run out of storage space, the file may not exist, or the content may not be valid text. Kotlin and Java express these problems with exceptions.
In Android file handling you often see IOException and FileNotFoundException. It is good practice to handle these errors explicitly, especially for non critical data such as caches or logs. This lets your app fail gracefully instead of crashing.
A typical pattern is:
try {
val data = File(filesDir, "settings.json").readText()
// Parse and use data
} catch (e: FileNotFoundException) {
// First run or file deleted, use defaults
} catch (e: IOException) {
// Could not read, show error or fall back
}
If file operations are not essential for the current user action, you can choose to log the error with Log.e(...) and continue execution with defaults. For important user data, you might show a message or retry later.
You should also handle malformed content. For example, if you store data in JSON format, the file can be present but contain invalid JSON. In that case your JSON parsing code can throw its own exception that you need to catch separately.
Text vs Binary Data
When you work with files, you must decide whether you treat the content as text or as bytes. Text files are easy to read and write, and you can inspect them in a debugger or on a rooted device. Binary files are better for images, audio, or custom compact formats.
In Kotlin, writeText and readText assume character data and use a character encoding. By default, they use UTF-8 encoding in modern Android versions, but you can also specify an explicit charset if needed:
file.writeText("Hello", charset = Charsets.UTF_8)
Binary operations work with ByteArray. You can convert a String to bytes and back explicitly:
val bytes: ByteArray = "Hello".toByteArray(Charsets.UTF_8)
val text: String = bytes.toString(Charsets.UTF_8)
If you ever read binary data as text by mistake, you can get unreadable characters or even errors when some byte sequences are not valid for the chosen encoding. You should keep your file types clear and consistent. For example, you can use a .txt extension for human readable data and a .bin extension for raw bytes.
Using Streams Safely
Streams like FileInputStream and FileOutputStream are powerful because they let you work with large files without loading the entire content in memory. However, they must be closed properly to avoid resource leaks.
Kotlin provides the use extension function to manage this. Whenever you work with streams in Android file handling, you should wrap them in use so the system closes them automatically.
openFileInput("large_file.dat").use { input ->
val buffer = ByteArray(4 * 1024) // 4 KB
var bytesRead = input.read(buffer)
while (bytesRead != -1) {
// Process buffer[0 until bytesRead]
bytesRead = input.read(buffer)
}
}
If you do not use streams correctly, your app can consume file descriptors or memory and eventually crash. The use pattern is the recommended way to avoid such issues.
Avoiding Main Thread Blocking
File I/O can be slow, especially with large files or on older devices. In Android, the main thread is responsible for drawing the UI and handling user input. If you perform slow file operations on the main thread, the app can freeze and the system may show an ANR dialog.
To prevent this, handle real file work outside the main thread. With coroutines, this usually means dispatching file tasks to Dispatchers.IO. A common pattern is:
lifecycleScope.launch {
val content = withContext(Dispatchers.IO) {
val file = File(filesDir, "big_data.txt")
if (file.exists()) file.readText() else ""
}
// Update UI with content on the main thread
textView.text = content
}
The key idea is that all file reading or writing happens inside the withContext(Dispatchers.IO) block. The outer code runs on the main thread and only updates the UI when the background work finishes.
If you are not using coroutines yet, you can create a plain background thread or use higher level APIs that already offload work from the main thread. The exact choice depends on your project architecture, but the rule is the same: do not run expensive file operations on the UI thread.
Simple Data Formats for Files
When you use files to store app data, you must choose a format for your content. In this chapter you do not replace databases or structured storage, but you can still follow simple patterns to keep your files understandable.
For small settings or flags, you can choose a simple key value text format like:
theme=dark
fontSize=16
notifications=true
You then parse each line, split at the = character, and interpret values in your code.
For more structured data, many apps use JSON strings. For example, you can store a list of objects as a JSON array string in a file:
[
{"name":"Alice","age":30},
{"name":"Bob","age":25}
]Your Kotlin code would serialize objects to JSON before writing them to the file and deserialize the JSON text after reading it. The details of JSON parsing belong to networking and data topics, so the important part here is that files usually store text in some structured format, not random lines that you cannot parse later.
Whenever you define a text format for your files, keep it consistent and predictable. A change in format without an upgrade path can make older files unreadable and cause crashes or data loss for your users.
When to Use Files vs Other Storage Options
File handling is useful when you need to store custom structured data, logs, user generated content, or cached resources. However, it is not always the right choice. For very small key value settings, other storage mechanisms are simpler and safer. For complex relational data, a database is often more appropriate.
In this chapter, the main focus is to give you practical tools to create, read, update, and delete files in your app internal storage. As you move through the course, you will learn other storage options. With that context, you can decide when file handling is the right approach and how to combine it with other data storage techniques in a complete Android application.