Build a File Library App for Android
Build a searchable Android app that catalogs and opens any file type.
Introduction
30 Second Summary
Think about all the files on your phone or tablet. Finding the right one usually means scrolling through folders and hoping you remember where you saved it.
In this project, you will build a personal file library app for Android that lets you import any file, tag it with a category, and find it instantly through a search bar.
What You'll Build
Picture opening your app on a tablet, tapping a + button to import a work document, then typing two letters into the search bar and watching it appear instantly.
By the end of this project, you'll have:
- A file import system that picks any file type from your device and tags it with a category like "Work" or "Personal" so your library stays organized.
- A real-time search bar and category filter chips that let you find any file by typing its name or browsing by group.
- A dark gray themed app where tapping any file in your library opens it in the right viewer on your device.
- Secret Mission: Add the ability to delete files with a long-press gesture and display file count badges next to each category.
Are there any prerequisites?
No prior experience with Android development is required. This guide walks you through installing every tool you need, starting from scratch.
Before We Start
Before diving into code, take a moment to picture what you're about to build and why it matters. You're creating a personal file library app for an Android tablet, and understanding the problem it solves will ground every step ahead.
Set Up Android Studio and Create Your Project
Before you can build anything, you need the development environment and a project skeleton.
Android Studio is the official IDE for Android development. It includes everything you need in one package: the code editor, the build tools, and a device emulator so you can test your app without a physical tablet.
In this step, get ready to:
- Install Android Studio Quail 2 and the Android SDK on your Linux machine.
- Create a new Compose project called FileLibrary.
- Set up a tablet emulator and run the default app on it.
Download and install Android Studio
Android Studio bundles the code editor, the Android SDK, and the emulator into a single download. You will grab it from the official site and unpack it on your Linux machine.
- Open your browser and go to developer.android.com/studio.
- Download the Linux .tar.gz package for Android Studio Quail 2.
- Open a terminal. Unpack the downloaded archive to /opt/ by running this command:
sudo tar -xzf ~/Downloads/android-studio-*-linux.tar.gz -C /opt/
- Launch Android Studio by running:
/opt/android-studio/bin/studio.sh
The Setup Wizard opens. It downloads the Android SDK and other essential components.
- Follow each page of the Setup Wizard and accept the default settings.
- Accept the license agreements when prompted.
- Wait for the SDK download to finish. This can take several minutes depending on your connection speed.
Once the wizard finishes, you should see the Android Studio Welcome screen with options to create a new project.
Setup Wizard not appearing?
Make sure you ran the command with the full path /opt/android-studio/bin/studio.sh. If you see a permission error, check that the extract completed successfully with ls /opt/android-studio/bin/.
Need more help? Help me troubleshoot launching Android Studio on Linux.
Create the FileLibrary project
Now that Android Studio is ready, you need a project to work in. The Empty Activity template with Jetpack Compose gives you a minimal starting point with the modern UI toolkit already wired up.
- On the Welcome screen, click **New Project**.
- Select Empty Activity (the Compose-based one) from the template list.
- Click Next.
- In the Name field, type FileLibrary.
- In the Package name field, enter com.example.filelibrary.
- Set the Minimum SDK dropdown to API 26 ("Android 8.0 (Oreo)").
- Click Finish.
Android Studio creates your project and starts downloading dependencies. A progress bar appears at the bottom of the window. Wait for it to complete before moving on.
What is a minimum SDK?
The minimum SDK sets the oldest Android version your app will support. API 26 means your app runs on Android 8.0 and every version after it.
A lower number supports more devices, but locks you out of newer features. API 26 is a good balance for this project because it covers the vast majority of active Android devices.
Configure the tablet emulator and run the app
You do not need a physical tablet to test your app. Android Studio includes an emulator that simulates a real device on your screen. You will create a virtual Pixel Tablet to run your app on.
- Open the Device Manager panel. You can find it in the right-side toolbar, or go to Tools in the top menu bar, then select Device Manager.
- Click the + button (or Create Virtual Device).
- In the hardware profile list, select the Tablet category on the left.
- Pick Pixel Tablet (or any large tablet hardware profile).
- Click Next.
- On the system image screen, select the API 34 image (look for the row labeled UpsideDownCake or API 34). If it shows a Download link next to it, click it and wait for the download to finish.
- Click Next, then click Finish to create the virtual device.
Your new Pixel Tablet now appears in the Device Manager list. Time to run your app on it.
Before you hit Run, what do you think you will see on the tablet screen? The project is brand-new with no custom code yet.
- Make sure your new Pixel Tablet is selected in the device dropdown at the top of the Android Studio toolbar.
- Click the green Run button (the play triangle) in the toolbar, or press Shift+F10.
The emulator boots up (this takes a minute the first time) and your app launches. You should see a large tablet screen displaying "Hello Android!" text on a white background.
Why a tablet emulator?
A tablet gives your file library plenty of room for the search bar, category chips, and the file list all at once. On a phone-sized screen, these elements would feel cramped.
Emulator not booting?
The first boot can take 2-3 minutes. If it stalls, check that hardware virtualization (KVM) is enabled on your Linux machine. You can verify by running egrep -c '(vmx|svm)' /proc/cpuinfo in a terminal. A result greater than 0 means KVM is supported.
Still stuck? Help me get the Android emulator running on Linux.
Your development environment is ready and your project skeleton is running on a tablet emulator. Next up, you will replace that plain white screen with a dark gray theme and build the layout for your file library.
Build the Dark Theme and App Layout
Your project is live on the tablet emulator, but it is a bright white screen with generic "Hello Android!" placeholder text. That is not a file library.
Before you can display any file data, you need the visual foundation in place. You will define a custom dark gray color scheme and build the main screen layout with a search bar at the top and an empty state message below.
In this step, get ready to:
- Define a dark color palette and custom Material 3 theme.
- Build the main screen layout with a search bar and empty state message.
- Verify the dark themed layout on the tablet emulator.
Define the dark color palette and theme
The default Jetpack Compose template comes with a purple-ish Material theme. You need a completely different palette of dark grays, light text, and blue accents.
- In the Android project panel on the left side of Android Studio, navigate to app > java > com.example.filelibrary > ui.theme.
- Double-click Color.kt to open it.
- Select all the existing code and replace it with this:
package com.example.filelibrary.ui.theme
import androidx.compose.ui.graphics.Color
// Background shades from darkest to lightest
val DarkBackground = Color(0xFF1E1E1E)
val DarkSurface = Color(0xFF2D2D2D)
val DarkSurfaceVariant = Color(0xFF3A3A3A)
// Text and interactive element colors
val LightText = Color(0xFFE0E0E0)
val AccentBlue = Color(0xFF64B5F6)
val AccentBlueDark = Color(0xFF1565C0)
val ChipSelected = Color(0xFF37474F)
What do these colors do?
DarkBackground (0xFF1E1E1E) is the darkest shade, used for the overall screen background. DarkSurface and DarkSurfaceVariant are slightly lighter grays for cards and input fields.
LightText provides readable contrast against dark backgrounds. AccentBlue is for interactive elements like buttons and selected chips.
- Save Color.kt.
✔️ Awesome, I've got everything!
Your color palette is ready. These colors take effect once you wire them into the theme in the next file.
ⓧ I'd like to double check the full code
package com.example.filelibrary.ui.theme
import androidx.compose.ui.graphics.Color
val DarkBackground = Color(0xFF1E1E1E)
val DarkSurface = Color(0xFF2D2D2D)
val DarkSurfaceVariant = Color(0xFF3A3A3A)
val LightText = Color(0xFFE0E0E0)
val AccentBlue = Color(0xFF64B5F6)
val AccentBlueDark = Color(0xFF1565C0)
val ChipSelected = Color(0xFF37474F)
- Open Theme.kt in the same ui.theme folder.
- Select all the existing code and replace it with this:
package com.example.filelibrary.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
// Map each Material 3 color role to a custom dark palette color
private val DarkColorScheme = darkColorScheme(
primary = AccentBlue,
onPrimary = DarkBackground,
primaryContainer = AccentBlueDark,
secondary = AccentBlue,
background = DarkBackground,
onBackground = LightText,
surface = DarkSurface,
onSurface = LightText,
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = LightText
)
// Always applies dark theme regardless of system setting
@Composable
fun FileLibraryTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DarkColorScheme,
content = content
)
}
What does this code do?
darkColorScheme() creates a Material 3 color scheme for dark mode. Each parameter maps a color role (like background, surface, onSurface) to one of your custom colors from Color.kt.
FileLibraryTheme is a composable wrapper that applies this dark scheme to everything inside it. The default template had a light/dark toggle based on system settings. Your version always uses the dark scheme, which is exactly what you want.
- Save Theme.kt.
Before you run the app, what do you think will change about the screen on the emulator?
- Click the green Run button in the Android Studio toolbar to rebuild and deploy the app.
The screen is now dark gray instead of white. The "Hello Android!" text still appears, but the background has changed to your custom dark color.
Still seeing a white screen?
Make sure you saved both Color.kt and Theme.kt. Unsaved files keep the old code.
Check that your Theme.kt file uses darkColorScheme() directly (no if/else switching between light and dark). If the old template code is still there, the system might pick the light scheme.
Help me troubleshoot my dark theme not showing up in Android Studio.
✔️ Awesome, I've got everything!
Make sure Theme.kt is saved and move on to building the layout.
ⓧ I'd like to double check the full code
package com.example.filelibrary.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
private val DarkColorScheme = darkColorScheme(
primary = AccentBlue,
onPrimary = DarkBackground,
primaryContainer = AccentBlueDark,
secondary = AccentBlue,
background = DarkBackground,
onBackground = LightText,
surface = DarkSurface,
onSurface = LightText,
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = LightText
)
@Composable
fun FileLibraryTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DarkColorScheme,
content = content
)
}
Set up the Scaffold and top bar
The dark background is in place, but the screen still shows the default "Hello Android!" composable. You need to replace it with a Scaffold layout that provides a top app bar and a content area below it.
- Open MainActivity.kt from the project panel (it is directly inside com.example.filelibrary, not inside ui.theme).
- Select all the code in the file and delete it.
- Paste in these imports as the new file header:
package com.example.filelibrary
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.filelibrary.ui.theme.FileLibraryTheme
What are these imports?
The foundation.layout imports provide layout containers like Column and Box. The material3 imports bring in UI components like Scaffold, TopAppBar, and TextField. The runtime imports enable reactive state management with remember and mutableStateOf.
- Right below the imports, add the class and the app's main composable function:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Wrap the entire app in the custom dark theme
setContent {
FileLibraryTheme {
FileLibraryApp()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FileLibraryApp() {
var searchQuery by remember { mutableStateOf("") } // Search bar text
Scaffold(
topBar = {
TopAppBar(
title = { Text("File Library") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
},
containerColor = MaterialTheme.colorScheme.background
) { paddingValues ->
}
}
What does this code do?
MainActivity is the entry point of your app. The setContent block wraps everything in FileLibraryTheme, applying your dark colors to every composable inside.
FileLibraryApp defines the screen layout. Scaffold provides the Material 3 layout structure with a TopAppBar displaying "File Library" as the title. The containerColor and titleContentColor pull directly from your dark theme, so everything matches.
- Save MainActivity.kt and click the green Run button.
The emulator now shows a dark gray screen with "File Library" in the top bar. The content area below is empty because you have not added anything to the Scaffold body yet.
Seeing a compilation error?
Make sure both code blocks are in the same MainActivity.kt file. The imports must come first, followed by the class and then the FileLibraryApp function.
If you see "Unresolved reference: FileLibraryTheme", confirm that your Theme.kt file is saved and that the package declaration at the top of Theme.kt says com.example.filelibrary.ui.theme.
Help me fix a compilation error in my MainActivity.kt after replacing the code.
Add the search bar and empty state
The top bar is in place. Now you need the two pieces of content that go below it: a search bar for filtering files, and a placeholder message for when the library is empty.
- In FileLibraryApp, find the empty Scaffold content body (the { paddingValues -> } block at the bottom of the function).
- Replace the empty body by pasting this code between { paddingValues -> and the closing } of the Scaffold:
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Search bar with magnifying glass icon
TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = { Text("Search files...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurface
)
)
// Empty state message centered on screen
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No files yet", color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f))
}
}
What does this code do?
The Column arranges its children vertically, filling the space below the top bar. paddingValues prevents the content from sliding behind the TopAppBar.
TextField is the search bar. It tracks what the user types in searchQuery and displays a magnifying glass icon with "Search files..." placeholder text. The surfaceVariant color makes it slightly lighter than the background so it stands out.
Box centers the "No files yet" message in the remaining space. The copy(alpha = 0.6f) makes the text slightly transparent so it reads as a secondary hint rather than primary content.
- Save MainActivity.kt.
✔️ Awesome, I've got everything!
Double check you have saved MainActivity.kt. All three sections should be present in order: imports, class declaration, and the FileLibraryApp composable.
ⓧ I'd like to double check the full code
Your MainActivity.kt should contain these sections in order:
- The package declaration and 28 import statements you pasted first.
- The MainActivity class with setContent wrapping FileLibraryTheme { FileLibraryApp() }.
- The FileLibraryApp composable with Scaffold, TopAppBar, TextField, and the Box with "No files yet" inside the Column.
Scroll up and confirm each section is present in your file. If anything is out of order or missing, copy the chunks above again.
Before you run the app, what do you expect to see below the "File Library" top bar?
- Click the green Run button to rebuild and deploy the app.
The entire screen is dark gray. A search bar sits at the top with a magnifying glass icon and "Search files..." placeholder text. Below it, the message "No files yet" is centered on screen.
Search bar not visible?
Make sure the Column code is pasted inside the Scaffold's content lambda, not outside the Scaffold block. The paddingValues variable is only available inside { paddingValues -> }.
If the search bar background blends into the screen, confirm that surfaceVariant in your Color.kt is set to Color(0xFF3A3A3A). This shade is slightly lighter than the background and makes the search bar stand out.
Help me figure out why my search bar is not showing up in my Compose app.
Your dark themed layout is locked in. In the next step, you will create a Room database to store file metadata so your library can remember files between sessions.
Create the Room Database and File Entity
You have a polished dark shell with a search bar and a placeholder message. It looks great, but it cannot store anything.
Without a database, any files you import would vanish the moment you close the app. Room is Android's database library built on top of SQLite. It gives you a local database that persists data on the device and verifies your queries at compile time so typos surface before the app even runs.
In this step, get ready to:
- Add Room dependencies and the KSP annotation processor to your project.
- Define a file entity, DAO, and database class to store and query file metadata.
- Create a ViewModel that exposes the file list as a reactive data stream and add a floating action button to the UI.
Add Room dependencies and the KSP plugin
Room's annotation processor needs KSP (Kotlin Symbol Processing) to read your database annotations and generate code at compile time. You need to register the KSP plugin and the Room libraries before you can create any entities.
- In Android Studio, open libs.versions.toml from the Gradle Scripts section in the left-hand project panel.
- Add the Room and KSP version entries to the [versions] section by pasting these lines below the existing entries:
room = "2.8.4"
ksp = "2.3.11"
- In the same file, add Room and KSP library and plugin entries to the [libraries] and [plugins] sections:
# Add these to [libraries]
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Add this to [plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
What does this configure?
The [versions] block pins Room to 2.8.4 and KSP to 2.3.11. The [libraries] block defines the two Room artifacts you need: the runtime library and the compiler that generates database code. The [plugins] block registers the KSP plugin so you can apply it to your module.
- Open the project-level build.gradle.kts (listed as build.gradle.kts (Project: FileLibrary) in Gradle Scripts).
- Add the KSP plugin alias to the plugins block with apply false so it is available but not yet applied:
plugins {
// ... existing plugins
alias(libs.plugins.ksp) apply false
}
- Open the module-level build.gradle.kts (listed as build.gradle.kts (Module: app) in Gradle Scripts).
- Apply the KSP plugin inside the plugins block:
plugins {
// ... existing plugins
alias(libs.plugins.ksp)
}
- In the same module-level file, add the Room dependencies to the dependencies block:
dependencies {
// ... existing dependencies
// Room database
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
}
- Click Sync Now in the banner that appears at the top of the editor.
Gradle downloads the Room libraries and configures KSP. The sync should complete without errors.
Sync failed?
Check that your ksp version in libs.versions.toml matches the Kotlin version your project uses. KSP versions are tied to specific Kotlin releases. Also confirm the room version is exactly 2.8.4.
help me fix a Gradle sync error after adding Room and KSP
Create the file entity
A Room entity is a Kotlin data class that maps to a database table. Each instance becomes a row, and each property becomes a column. Your FileItem entity stores everything the app needs to know about an imported file.
- In the project panel, right-click the com.example.filelibrary package folder.
- Select New, then Package.
- Name the new package data (full path: com.example.filelibrary.data).
- Right-click the new data package, select New then Kotlin Class/File, and name it FileItem.
- Replace the file contents with the following entity class:
package com.example.filelibrary.data
import androidx.room.Entity
import androidx.room.PrimaryKey
// Each FileItem becomes a row in the "files" table
@Entity(tableName = "files")
data class FileItem(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val name: String,
val category: String,
val mimeType: String,
val uriString: String
)
What does this code do?
The @Entity annotation tells Room to create a SQLite table named files from this class. Each property (name, category, mimeType, uriString) becomes a column.
The id field is the primary key. autoGenerate = true means Room assigns a unique integer automatically each time you insert a row. The app stores the file's URI as a string, not the file itself, so your database stays lightweight no matter how many files you catalog.
- Save FileItem.kt.
Create the DAO and database class
A DAO (Data Access Object) defines the queries your app can run against the database. The database class ties the entity and DAO together into a Room database instance.
- Right-click the data package, select New then Kotlin Class/File, and name it FileDao.
- Replace the file contents with the following DAO interface:
package com.example.filelibrary.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface FileDao {
@Insert
suspend fun insert(file: FileItem)
@Delete
suspend fun delete(file: FileItem)
@Query("SELECT * FROM files ORDER BY name ASC")
fun getAllFiles(): Flow<List<FileItem>>
@Query("SELECT * FROM files WHERE name LIKE '%' || :query || '%' ORDER BY name ASC")
fun searchFiles(query: String): Flow<List<FileItem>>
@Query("SELECT * FROM files WHERE category = :category ORDER BY name ASC")
fun getFilesByCategory(category: String): Flow<List<FileItem>>
@Query("SELECT * FROM files WHERE category = :category AND name LIKE '%' || :query || '%' ORDER BY name ASC")
fun searchFilesInCategory(category: String, query: String): Flow<List<FileItem>>
@Query("SELECT DISTINCT category FROM files ORDER BY category ASC")
fun getAllCategories(): Flow<List<String>>
}
What does this code do?
The @Dao annotation marks this interface so Room generates the implementation automatically. insert and delete are suspend functions, meaning they run on a background thread using Kotlin coroutines.
The query methods return Flow, which is a reactive stream. Whenever data in the table changes, Room automatically pushes an updated list through the Flow. The searchFiles query uses SQL LIKE with wildcards so typing part of a filename returns all matches.
- Save FileDao.kt.
- Create another file in the data package named AppDatabase.
- Replace the file contents with the following database class:
package com.example.filelibrary.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [FileItem::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun fileDao(): FileDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
// Singleton pattern: only one database instance across the app
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"file_library_db"
).build()
INSTANCE = instance
instance
}
}
}
}
What does this code do?
The @Database annotation registers FileItem as an entity and sets the schema version to 1. The abstract fileDao() method lets Room generate and return the DAO implementation.
The getDatabase function uses the singleton pattern so the entire app shares one database connection. @Volatile and synchronized prevent race conditions if two threads try to create the database at the same time.
- Save AppDatabase.kt.
- Click the green play button in the toolbar to rebuild the project.
The project should compile without errors. If you see a build error, check that FileItem.kt, FileDao.kt, and AppDatabase.kt are all in the com.example.filelibrary.data package.
Build errors about unresolved references?
Make sure FileItem is imported in FileDao.kt and AppDatabase.kt. Android Studio usually adds imports automatically, but you can press Alt+Enter on any red-underlined reference to trigger an import.
help me fix Room build errors in my Android project
Create the ViewModel and add the floating action button
The ViewModel sits between the UI and the database. It holds the current search query and category filter, and uses Room's reactive Flow queries to keep the file list up to date whenever data changes.
- In the project panel, expand the com.example.filelibrary.ui package (it already exists from the theme files).
- Right-click the ui package, select New then Kotlin Class/File, and name it FileViewModel.
- Replace the file contents with the class declaration, imports, and reactive state setup:
package com.example.filelibrary.ui
import android.app.Application
import android.net.Uri
import android.provider.OpenableColumns
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.filelibrary.data.AppDatabase
import com.example.filelibrary.data.FileItem
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class FileViewModel(application: Application) : AndroidViewModel(application) {
// Get the DAO from the database singleton
private val dao = AppDatabase.getDatabase(application).fileDao()
// Tracks what the user types in the search bar
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
// Tracks which category chip is selected (null = show all)
private val _selectedCategory = MutableStateFlow<String?>(null)
val selectedCategory: StateFlow<String?> = _selectedCategory.asStateFlow()
}
What does this code do?
The class extends AndroidViewModel because the DAO needs the application context to build the database. MutableStateFlow holds the current search text and category filter. The public StateFlow versions are read-only so the UI can observe them but not change them directly.
- Inside the FileViewModel class body (before the closing }), add the reactive file list and category queries:
// All distinct categories from the database
val categories: StateFlow<List<String>> = dao.getAllCategories()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// Reactively switch between queries based on search + category
@OptIn(ExperimentalCoroutinesApi::class)
val files: StateFlow<List<FileItem>> = combine(
_searchQuery, _selectedCategory
) { query, category ->
Pair(query, category)
}.flatMapLatest { (query, category) ->
when {
category != null && query.isNotBlank() -> dao.searchFilesInCategory(category, query)
category != null -> dao.getFilesByCategory(category)
query.isNotBlank() -> dao.searchFiles(query)
else -> dao.getAllFiles()
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
What does flatMapLatest do here?
Every time the search query or selected category changes, combine emits the new pair. flatMapLatest cancels the previous database query and starts a new one matching the latest filters. This means the file list updates instantly as the user types or taps a category chip.
- Below the files property (still inside the class), add the helper methods:
fun updateSearchQuery(query: String) {
_searchQuery.value = query
}
fun selectCategory(category: String?) {
_selectedCategory.value = if (_selectedCategory.value == category) null else category
}
fun addFile(uri: Uri, category: String) {
val context = getApplication<Application>()
val contentResolver = context.contentResolver
val name = contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst()
cursor.getString(nameIndex)
} ?: "Unknown file"
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
viewModelScope.launch {
dao.insert(FileItem(name = name, category = category, mimeType = mimeType, uriString = uri.toString()))
}
}
fun deleteFile(file: FileItem) {
viewModelScope.launch {
dao.delete(file)
}
}
What do these methods do?
The updateSearchQuery method updates the search flow, which triggers flatMapLatest to re-query. selectCategory toggles a category filter on and off.
The addFile method reads the file name and MIME type from the URI using Android's ContentResolver, then inserts a new FileItem into the database. The deleteFile method removes a file record from the database.
- Save FileViewModel.kt.
Now wire the ViewModel into the UI and add the "+" button.
- Return to MainActivity.kt.
- Find the existing Scaffold composable. Add a floatingActionButton parameter to the Scaffold that shows a "+" icon:
// Add this parameter to your existing Scaffold call
floatingActionButton = {
FloatingActionButton(
onClick = { /* will wire up in next step */ },
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(Icons.Default.Add, contentDescription = "Add file")
}
},
What does this add?
The FloatingActionButton renders a circular "+" button in the bottom-right corner of the screen. The onClick is empty for now. You will connect it to the file picker in the next step.
- Add the necessary imports at the top of MainActivity.kt if Android Studio has not added them automatically:
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
- Save MainActivity.kt.
Before you run the app, what do you think you will see on screen? Will the file list look different from before?
- Click the green play button to build and run the app on the tablet emulator.
You should see the same dark screen with the search bar at the top and the "No files yet" message. The new addition is a blue circular "+" button in the bottom-right corner. Tapping it does nothing yet, but the fact that the app compiles and runs means your entire database layer is wired up correctly.
Don't see the + button?
Make sure you added the floatingActionButton parameter inside the Scaffold() call, not outside it. Also confirm the Icons.Default.Add import resolved correctly. Press Alt+Enter on any red reference to auto-import.
help me fix my FloatingActionButton not showing in Jetpack Compose
✔️ Awesome, I've got everything!
Great. Double check you have saved all four new files: FileItem.kt, FileDao.kt, AppDatabase.kt, and FileViewModel.kt.
ⓧ I'd like to double check the full code
You built four files in this step across the data and ui packages. Scroll up and confirm each one is present and matches these names in order:
- FileItem.kt in com.example.filelibrary.data (the @Entity class with five fields).
- FileDao.kt in com.example.filelibrary.data (the @Dao interface with seven query methods).
- AppDatabase.kt in com.example.filelibrary.data (the @Database singleton class).
- FileViewModel.kt in com.example.filelibrary.ui (the AndroidViewModel with state flows and helper methods).
- MainActivity.kt updated with a FloatingActionButton in the Scaffold.
Your database layer is compiled, your ViewModel is ready to manage state, and the "+" button is waiting for action. Next up, you will connect that button to the system file picker so you can actually import files into your library.
Import Files with Category Tagging
Your database is compiled and ready, but it has zero files in it. Tapping the "+" button does nothing.
In this step, you will wire the "+" button to Android's built-in file picker, let the user tag each file with a category, and display every imported file in a scrollable list.
In this step, get ready to:
- Launch the system file picker from the "+" button.
- Build a category selection dialog that saves the file to Room.
- Display imported files as cards in a scrollable list.
Wire the FAB to open the system file picker
Android's Storage Access Framework provides a unified file picker that works with every file type on the device. Instead of building your own file browser, you launch the system picker and receive back a content URI pointing to the chosen file.
- Switch back to MainActivity.kt in Android Studio.
- Inside the FileLibraryApp composable function, find the line where viewModel is declared. Add the following code directly below it:
val context = LocalContext.current
// State to control the category selection dialog
var showCategoryDialog by remember { mutableStateOf(false) }
var pendingFileUri by remember { mutableStateOf<Uri?>(null) }
// Launch the system file picker when the user taps '+'
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
uri?.let {
// Persist URI access so the app can open the file after restarts
context.contentResolver.takePersistableUriPermission(
it,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
pendingFileUri = it
showCategoryDialog = true
}
}
What does this code do?
showCategoryDialog and pendingFileUri are Compose state variables. When a file is picked, the callback stores its URI and flips the dialog flag to true.
rememberLauncherForActivityResult uses the Activity Result API to launch the system file picker. takePersistableUriPermission keeps the URI accessible even after the app restarts. Without it, the content URI becomes invalid once the activity is destroyed.
Now the launcher exists, but the "+" button still does not call it. Time to connect them.
- Scroll down to the FloatingActionButton inside the Scaffold.
- Find the empty onClick handler:
onClick = { }
- Replace it with this line so the FAB launches the file picker for any file type:
onClick = { filePickerLauncher.launch(arrayOf("*/*")) }
What does */* mean?
The "*/*" string is a MIME type wildcard. It tells the file picker to accept every file type: PDFs, images, audio, video, documents, and anything else on the device.
Let Android Studio handle imports
You will see red highlights on new class names like Uri, Intent, and ActivityResultContracts. Place your cursor on each red name and press Alt+Enter to auto-import it.
- Save MainActivity.kt.
- Click the green Run button (or press Shift+F10) to rebuild the app on the tablet emulator.
- Tap the "+" button on the dark screen. The system file picker should open, showing files and folders on the device.
File picker not opening?
- Check that the onClick line in your FloatingActionButton calls filePickerLauncher.launch(arrayOf("*/*")) and not the old empty lambda.
- Make sure all red import errors are resolved with Alt+Enter before running.
- Why won't my file picker open when I tap the + button?
Build the category selection dialog
The file picker returns a URI, but you still need a way to ask the user which category the file belongs to. An AlertDialog pops up right after a file is picked, showing five preset category buttons.
- In MainActivity.kt, add this code inside FileLibraryApp, just above the Scaffold block:
val categoryOptions = listOf("Work", "Personal", "Reference", "Media", "Other")
if (showCategoryDialog && pendingFileUri != null) {
AlertDialog(
onDismissRequest = {
showCategoryDialog = false
pendingFileUri = null
},
title = { Text("Choose a category") },
text = {
Column {
categoryOptions.forEach { category ->
TextButton(onClick = {
// Save the file to Room with the chosen category
viewModel.addFile(pendingFileUri!!, category)
showCategoryDialog = false
pendingFileUri = null
}, modifier = Modifier.fillMaxWidth()) {
Text(category, modifier = Modifier.fillMaxWidth())
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = {
showCategoryDialog = false
pendingFileUri = null
}) { Text("Cancel") }
}
)
}
What does this code do?
The dialog only renders when showCategoryDialog is true and a file URI is waiting. Each category button is a TextButton that calls viewModel.addFile() when tapped.
The addFile method in your ViewModel queries the ContentResolver to extract the file's display name and MIME type from the URI, then inserts a new FileItem into the Room database.
- Save MainActivity.kt and run the app on the emulator.
- Tap the "+" button, then pick any file from the picker.
- You should see a dialog pop up with five category options: Work, Personal, Reference, Media, and Other.
Dialog not appearing after picking a file?
- Confirm that the if (showCategoryDialog && pendingFileUri != null) block is placed inside the FileLibraryApp function but outside the Scaffold. Placing it inside the Scaffold's content lambda can cause layout issues.
- Double check that your file picker callback sets both pendingFileUri = it and showCategoryDialog = true. Both must be set for the dialog condition to pass.
- Why doesn't my AlertDialog appear after I pick a file?
Display imported files in a scrollable list
Selecting a category saves the file to Room, but you cannot see it yet. The screen still shows "No files yet" because the UI is not observing the database. You need to collect the file list from your ViewModel and render each file as a card.
- At the top of the FileLibraryApp function, near your other state declarations, add this line to observe the file list:
// Observe the file list from the Room database
val files by viewModel.files.collectAsState()
- Now scroll down inside the Scaffold's content, find the Box that shows "No files yet", and replace it with this code that conditionally shows either the empty message or a file list:
if (files.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "No files yet. Tap + to add files.",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(files, key = { it.id }) { file ->
// Each file rendered as a Card
}
}
}
What does this code do?
When files is empty, the screen shows a helpful message. Once files exist, a LazyColumn renders them as a scrollable list. The key = { it.id } parameter helps Compose track items efficiently when the list changes.
- Inside the items block (replacing the // Each file rendered as a Card comment), add this Card composable that displays each file's details:
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// File details column
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
// MIME type
Text(
text = file.mimeType,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
Spacer(modifier = Modifier.width(8.dp))
// Category tag chip
AssistChip(onClick = { viewModel.selectCategory(file.category) },
label = { Text(file.category, style = MaterialTheme.typography.labelSmall) })
}
}
What does this card display?
- file.name is shown as the primary text, truncated with an ellipsis if it overflows one line.
- file.mimeType is shown as a smaller, dimmed label beneath the name (e.g. application/pdf).
- An AssistChip on the right displays the file's category as a tappable tag.
Before you run the app, what do you think will happen when you tap "+", pick a file, and choose a category?
- Save MainActivity.kt and run the app on the emulator.
- Tap the "+" button, pick a file, and select a category from the dialog.
- The file should appear in the list as a dark card showing the file name, the MIME type below it, and a category chip on the right.
- Import 2-3 more files with different categories to see the list grow.
Files not appearing in the list?
- Confirm that val files by viewModel.files.collectAsState() is declared at the top of FileLibraryApp. Without it, the UI has no data to display.
- Check that the if (files.isEmpty()) block fully replaced the old static Box. If both exist, you may see the "No files yet" text on top of the list.
- Make sure all imports (especially LazyColumn, Card, AssistChip) are resolved with Alt+Enter.
- My imported files are not showing up in the LazyColumn. Help me debug.
✔️ Awesome, I've got everything!
Double check that you have saved MainActivity.kt. You should have at least 2-3 files visible in your list on the emulator.
ⓧ I'd like to double check the full code
Your MainActivity.kt file is long at this point. Scroll through it and confirm each of these sections is present, in order:
- Inside FileLibraryApp: val files by viewModel.files.collectAsState() and val context = LocalContext.current.
- State variables: showCategoryDialog and pendingFileUri.
- filePickerLauncher with ActivityResultContracts.OpenDocument() and takePersistableUriPermission in its callback.
- categoryOptions list and the AlertDialog with five TextButton categories, calling viewModel.addFile().
- Scaffold with FloatingActionButton calling filePickerLauncher.launch(arrayOf("*/*")).
- Inside the Column after the TextField: the if (files.isEmpty()) conditional with LazyColumn in the else branch.
- Each list item: a Card containing a Row with file.name, file.mimeType, and an AssistChip showing file.category.
Your library can store files, but you cannot find them or open them yet. Next up, you will connect the search bar to filter files in real time and make every card open its file with a single tap.
Add Search and Open Files on Tap
Your file library is taking shape. You can import files, tag them with categories, and see them listed on a dark-themed screen.
There is a usability gap: the search bar is purely decorative, and tapping a file card does nothing. In this step, you will wire up live search filtering, make every card tappable so it opens its file, and add category filter chips that let you narrow down the list with one tap.
In this step, get ready to:
- Connect the search bar to the ViewModel so typing filters the file list in real time.
- Make each file card open its file in the device's default viewer app on tap.
- Add category filter chips below the search bar for one-tap filtering.
Connect the search bar to live filtering
The ViewModel already contains a searchQuery StateFlow and a files StateFlow that automatically switches between different Room queries depending on the search text. The search bar in the UI is not connected to any of that yet. You need to point the TextField at the ViewModel so every keystroke triggers a new database query.
- In MainActivity.kt, find the top of the FileLibraryApp composable where you collect state from the ViewModel.
- Make sure these two lines exist alongside your existing files collection. If they are not there yet, add them:
// Collect the current search text from the ViewModel
val searchQuery by viewModel.searchQuery.collectAsState()
val context = LocalContext.current
- Scroll down to the TextField inside the Column (below the Scaffold's paddingValues). Find the value and onValueChange parameters.
- Update them so the TextField reads from and writes to the ViewModel. The full TextField should now look like this:
// Search bar connected to ViewModel query
TextField(
value = searchQuery,
onValueChange = { viewModel.updateSearchQuery(it) },
placeholder = { Text("Search files...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurface
)
)
What does this change?
Two parameters do all the work.
- value = searchQuery reads the current search text from the ViewModel's StateFlow, so the field always reflects the latest query.
- onValueChange = { viewModel.updateSearchQuery(it) } pushes every keystroke into the ViewModel, which triggers flatMapLatest to switch to the matching Room query. The file list updates automatically.
- While you are in the empty state section, update the text so it shows a different message when a search returns no results. Find the Text inside the Box that shows when the file list is empty and replace it with this:
// Show a different message when search returns nothing
Text(
text = if (searchQuery.isNotBlank()) "No files match your search"
else "No files yet. Tap + to add files.",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Why two messages?
When the list is empty because no files exist, the learner needs guidance ("Tap + to add files."). When the list is empty because a search matched nothing, a different message ("No files match your search") tells them the database is not empty. The searchQuery.isNotBlank() check distinguishes the two cases.
- Save MainActivity.kt.
- Run the app on the tablet emulator.
- Type part of an imported file's name into the search bar. You should see the list shrink to show only files whose names contain what you typed.
- Clear the search bar. All your files should reappear.
Typing but the list doesn't change?
- Confirm the TextField's onValueChange calls viewModel.updateSearchQuery(it), not a local state variable.
- Make sure val searchQuery by viewModel.searchQuery.collectAsState() exists at the top of FileLibraryApp. Without it, the TextField does not reflect the ViewModel's query.
- Check that your search query in the ViewModel uses LIKE '%' || :query || '%' with single quotes around the percent signs.
Why isn't my search bar filtering the file list in my Compose app?
Make each file card open on tap
The file cards are display-only right now. You want two interactions: a regular tap opens the file in its default viewer, and a long press triggers a delete confirmation. Jetpack Compose provides combinedClickable to handle both gestures on the same composable.
- At the top of MainActivity.kt, add these three imports:
import android.content.Intent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
- Find the @OptIn annotation above the FileLibraryApp function and update it to include ExperimentalFoundationApi:
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
- Inside FileLibraryApp, add a new state variable alongside your existing showCategoryDialog and pendingFileUri declarations:
// Track which file the user wants to delete
var fileToDelete by remember { mutableStateOf<FileItem?>(null) }
- Add a delete confirmation dialog by pasting the following code right after your existing category selection AlertDialog block (the one that starts with if (showCategoryDialog && pendingFileUri != null)):
// Delete confirmation dialog
if (fileToDelete != null) {
AlertDialog(
onDismissRequest = { fileToDelete = null },
title = { Text("Delete file?") },
text = { Text("Remove \"${fileToDelete!!.name}\" from your library?") },
confirmButton = {
TextButton(onClick = {
viewModel.deleteFile(fileToDelete!!)
fileToDelete = null
}) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { fileToDelete = null }) {
Text("Cancel")
}
}
)
}
What does this dialog do?
- fileToDelete holds a reference to the file the user wants to remove. When it is not null, the dialog appears.
- Tapping Delete calls viewModel.deleteFile(), which runs Room's @Delete query and removes the row from the database.
- Tapping Cancel sets fileToDelete back to null, which dismisses the dialog without deleting anything.
- Now update the file Card to be interactive. Find the Card composable inside the LazyColumn and replace the entire Card block (including its content) with this version that adds tap-to-open and long-press-to-delete:
Card(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
// Open the file in its default viewer app
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(Uri.parse(file.uriString), file.mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
},
onLongClick = { fileToDelete = file }
),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
What does combinedClickable do?
- onClick fires on a regular tap. It builds an ACTION_VIEW Intent with the file's stored URI and MIME type, then launches the device's default app for that file type (PDF reader, image viewer, audio player).
- FLAG_GRANT_READ_URI_PERMISSION passes the file access permission along to the receiving app. Without it, the viewer app would get a permission denied error.
- onLongClick fires when the user presses and holds. It sets fileToDelete, which triggers the delete confirmation dialog you just added.
- Keep the Card's inner content (the Row with file name, MIME type, and AssistChip) the same. Update the AssistChip's onClick to call viewModel.selectCategory(file.category) so tapping a category chip on a card filters by that category:
// Tapping the category chip filters the list to that category
AssistChip(
onClick = { viewModel.selectCategory(file.category) },
label = { Text(file.category, style = MaterialTheme.typography.labelSmall) }
)
- Save MainActivity.kt and run the app on the tablet emulator.
- Tap one of your imported file cards. The file should open in its default viewer app (a PDF reader, image viewer, or audio player depending on the file type).
- Long-press a file card. You should see the "Delete file?" dialog. Tap Cancel for now to keep your test files.
App crashes when you tap a file?
- Check that you added import android.content.Intent at the top of the file. Missing this import causes an unresolved reference error.
- Make sure addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) is inside the apply block. Without the flag, the viewer app cannot read the file's content URI.
- Verify that ExperimentalFoundationApi::class is included in the @OptIn annotation on the FileLibraryApp function.
Why does my app crash when I tap a file card in my Compose app?
Add category filter chips below the search bar
Search is great for finding a specific file by name. Category filter chips give users a faster way to browse when they remember the tag but not the exact file name.
- At the top of MainActivity.kt, add these two imports:
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material3.FilterChip
- At the top of FileLibraryApp, add these two state collection lines alongside your existing files and searchQuery collections:
// Collect distinct categories and the currently selected filter
val categories by viewModel.categories.collectAsState()
val selectedCategory by viewModel.selectedCategory.collectAsState()
- Inside the Column in the Scaffold, find the spot between the TextField (search bar) and the if (files.isEmpty()) check. Add the category filter chips row by pasting this code between them:
// Category filter chips - one per distinct category in the database
if (categories.isNotEmpty()) {
LazyRow(
modifier = Modifier.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(categories) { category ->
FilterChip(
selected = selectedCategory == category,
onClick = { viewModel.selectCategory(category) },
label = { Text(category) }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
How do the filter chips work?
- categories is a StateFlow from the ViewModel that queries all distinct categories in the database. When you import a file and tag it "Work", a "Work" chip appears automatically.
- viewModel.selectCategory(category) toggles the category filter on or off. The ViewModel's flatMapLatest logic then switches to the correct DAO query (getFilesByCategory, searchFilesInCategory, or getAllFiles) depending on whether a category is selected and whether there is search text.
- selected = selectedCategory == category visually highlights the active chip so the user knows which filter is applied.
Before you run the app, think about what you expect to see. If you have files tagged "Work" and "Personal", how many chips should appear? What happens if you tap "Work" and then type a search term?
- Save MainActivity.kt and run the app on the tablet emulator.
- Below the search bar, you should see a horizontal row of filter chips. One chip appears for each category you have used (e.g. "Work", "Personal").
- Tap a category chip. The file list should filter to only show files in that category.
- Tap the same chip again. The filter clears and all files reappear.
- Try combining both: tap a category chip, then type in the search bar. The list should narrow to files that match both the category and the search text.
No chips showing up?
- Chips only appear after you have imported at least one file with a category. If your file list is empty, import a file first using the "+" button.
- Make sure the LazyRow block is placed between the TextField and the if (files.isEmpty()) check, not outside the Column.
- Confirm you added import androidx.compose.material3.FilterChip at the top of the file.
Why are my FilterChip composables not appearing in my Compose LazyRow?
✔️ Awesome, I've got everything!
Great. Make sure you have saved MainActivity.kt.
ⓧ I'd like to double check the full code
MainActivity.kt is a large file at this point. Scroll through it and confirm each of these sections is present, in order:
- Imports including android.content.Intent, ExperimentalFoundationApi, combinedClickable, LazyRow, and FilterChip.
- @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) on the FileLibraryApp function.
- State collection lines for files, searchQuery, categories, selectedCategory, and context.
- Mutable state for showCategoryDialog, pendingFileUri, and fileToDelete.
- The category selection AlertDialog (from the previous step).
- The delete confirmation AlertDialog (if (fileToDelete != null)).
- Scaffold with TopAppBar, FAB, and inner Column.
- TextField with value = searchQuery and onValueChange = { viewModel.updateSearchQuery(it) }.
- LazyRow of FilterChip composables between the TextField and the file list.
- Empty state with conditional text based on searchQuery.isNotBlank().
- LazyColumn with Cards using combinedClickable (onClick with ACTION_VIEW Intent, onLongClick setting fileToDelete).
All other files (FileViewModel.kt, FileDao.kt, FileItem.kt, AppDatabase.kt, Color.kt, Theme.kt) are unchanged from the previous steps.
Your file library is fully functional. You can import files, search them by name, filter by category, open them with a tap, and remove them with a long press. Next up, the Secret Mission challenges you to add file count badges to each category chip and confirm the delete workflow end to end.
Secret mission
Delete Files and Add Category Counts
Your file library can import, search, and open files. But how many files does each category actually contain? Add count badges to your category filter chips so you can see the answer at a glance.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources in place, pause the emulator, or delete everything entirely.
Resources you used:
- FileLibrary project directory (local files in ~/AndroidStudioProjects/FileLibrary).
- Pixel Tablet virtual device (Android Emulator).
- Android Studio and Android SDK installation.
Keep everything running
No action needed. Choose this if you plan to keep building on this app or start the next project soon.
- Your FileLibrary project stays in ~/AndroidStudioProjects/FileLibrary and can be reopened in Android Studio at any time.
- Your Pixel Tablet virtual device stays in the Device Manager, ready to launch whenever you need it.
- Android Studio and the Android SDK remain installed for future projects.
Pause - I'll come back to this later
Shut down the emulator to free up system memory. Your project files and virtual device stay on disk so you can pick up where you left off.
- Close the emulator window if it is still running.
- Close Android Studio. Your project saves automatically.
- When you are ready to resume, reopen Android Studio and your FileLibrary project will appear on the welcome screen.
Delete - I don't want to use this again
Remove all project resources and optionally uninstall Android Studio entirely.
- Delete the FileLibrary project directory by running this command in your terminal:
rm -rf ~/AndroidStudioProjects/FileLibrary
- Delete the Pixel Tablet virtual device. In Android Studio, open Device Manager from the right sidebar. Click the overflow menu (three dots) next to the Pixel Tablet device and select Delete.
- If you want to remove Android Studio itself, delete the installation directory you extracted during setup:
sudo rm -rf /opt/android-studio
- Remove the Android SDK and any cached emulator data by running:
rm -rf ~/Android/Sdk
- Remove Android Studio configuration and cache files from your home directory:
rm -rf ~/.config/Google/AndroidStudio*
rm -rf ~/.local/share/Google/AndroidStudio*
Nice Work!
Nice Work!
You just built a fully functional file library app for Android from scratch. Your app imports files, stores them in a searchable database, and opens them with a single tap.
Here's what you learned:
- Built a database-backed Android app using Kotlin and Jetpack Compose, with Room entities, DAOs, and reactive Flow queries that store and retrieve unlimited file metadata.
- Used Android's Storage Access Framework to import any file type through the system file picker, with persistent URI permissions that survive app restarts.
- Created a touch-driven UI with a custom dark Material 3 theme, real-time search filtering, category chips, and one-tap file opening via Android intents.
- Secret Mission: Added file deletion with long-press gestures and live category count badges on each filter chip.
Ready to quiz yourself?