Google Drive Sheets Navigator
Build an Android app to browse Google Drive and explore spreadsheet data.
Introduction
30 Second Summary
If you use Google Sheets, you probably have dozens of spreadsheets scattered across nested Drive folders. Finding the right sheet and remembering what columns it contains can feel like a treasure hunt.
In this project, you will build an Android app that connects to your Google account, displays your Drive as a filtered tree showing only folders that contain Google Sheets, and lets you drill into any sheet to see its tabs and column headers.
What You'll Build
A Kotlin and Jetpack Compose Android app that authenticates with Google, browses your Drive folder hierarchy filtered to Google Sheets only, and navigates into individual spreadsheets to reveal their tabs and columns as interactive cards.
By the end of this project, you'll have:
- Google Sign-In via Credential Manager, authenticating users and obtaining an access token for API calls.
- A Drive tree browser that displays only folders containing Google Sheets, with empty or irrelevant folders pruned away.
- A multi-screen navigation flow from the Drive tree to sheet tabs to column headers, all displayed as Material 3 cards.
- Secret Mission: Add a real-time search bar that filters the entire Drive tree as you type.
Are there any prerequisites?
This project assumes you have experience with Kotlin and Jetpack Compose. You should also have Android Studio installed and a Google account ready to use.
Set Up Google Cloud Console and Android Project
To connect your app to Google Drive and Google Sheets, you need a bridge between your Android app and Google's servers. Google Cloud Console provides this bridge by managing API access and authenticating your app.
In this step, you will create a Google Cloud project, enable the APIs your app needs, set up authentication credentials, and scaffold your Android project with all required dependencies.
In this step, get ready to:
- Create a Google Cloud project with the Drive API and Sheets API enabled.
- Configure OAuth credentials for your Android app.
- Scaffold a Jetpack Compose Android project with all dependencies.
Create a Google Cloud project and enable APIs
Google requires you to register a Cloud project before your app can call any Google API. This project acts as the container for API access, quotas, and credentials.
- Open your browser and navigate to console.cloud.google.com.
- Sign in with your Google account.
- Click Select a project in the top navigation bar, then click New Project.
- Enter a project name (e.g. Drive Sheets Navigator) and click Create.
- Wait for the notification confirming your project was created, then select it from the project dropdown.
Now enable the two APIs your app will use.
- In the left sidebar, click APIs & Services, then Library.
- Search for Google Drive API and click on it.
- Click Enable.
- Return to the Library page and search for Google Sheets API.
- Click Enable.
Why two separate APIs?
The Drive API lets your app browse folders and list files. The Sheets API lets your app read spreadsheet metadata like tab names and cell values. Each API must be enabled separately in your Cloud project.
Next, configure the OAuth consent screen so Google knows how to present the login prompt to users.
- In the left sidebar, click APIs & Services, then OAuth consent screen.
- Select External as the user type and click Create.
- Fill in the App name field (e.g. Drive Sheets Navigator).
- Enter your email in both the User support email and Developer contact information fields.
- Click Save and Continue through the Scopes and Test users pages (you can skip adding scopes here).
- On the Test users page, click Add Users and enter your own Google email address.
- Click Save and Continue, then Back to Dashboard.
What is Testing mode?
When an OAuth consent screen is set to External with Testing status, only the email addresses you add as test users can sign in. This avoids the need for Google's app verification process, which is only required for production apps.
Don't see the OAuth consent screen option?
Make sure you have selected your new project from the project dropdown at the top of the page. The OAuth consent screen option only appears under APIs & Services when a project is active.
Help me find the OAuth consent screen in Google Cloud Console.
Create OAuth 2.0 client IDs
Your app needs two OAuth client IDs. The Android client ID ties your app's package name and signing certificate to Google's auth system. The Web client ID provides the serverClientId value that Credential Manager requires.
First, get your debug keystore's SHA-1 fingerprint. This identifies your app to Google during development.
- Open a terminal and run the following command to print your debug certificate's SHA-1 fingerprint:
macOS
keytool -exportcert -alias androiddebugkey -keystore ~/.android/debug.keystore -list -v
When prompted for a password, type android and press Enter.
Windows
keytool -exportcert -alias androiddebugkey -keystore C:\Users\%USERNAME%\.android\debug.keystore -list -v
When prompted for a password, type android and press Enter.
- Find the line labeled SHA1: in the output and copy the full fingerprint (a colon-separated hex string like AB:CD:EF:12:34:...).
What is a debug keystore?
Android Studio automatically creates a debug keystore the first time you build an app. It lives at ~/.android/debug.keystore on macOS/Linux or C:\Users\<user>\.android\debug.keystore on Windows. The SHA-1 fingerprint from this keystore tells Google which app is making the OAuth request.
Getting 'keystore file does not exist'?
The debug keystore is only generated after your first build. If this is a fresh Android Studio install, create any new project, build it once, then re-run the keytool command.
Help me fix the missing debug keystore error.
Now create the Android client ID in Google Cloud Console.
- In the left sidebar, click APIs & Services, then Credentials.
- Click + Create Credentials at the top, then select OAuth client ID.
- Set Application type to Android.
- Enter your app's package name: com.example.drivesheetsnavigator.
- Paste the SHA-1 fingerprint you copied from the keytool output into the SHA-1 certificate fingerprint field.
- Click Create.
Next, create the Web client ID.
- Click + Create Credentials again, then select OAuth client ID.
- Set Application type to Web application.
- Give it a name (e.g. Web client for Credential Manager).
- Leave all other fields empty and click Create.
- Copy the Client ID value shown in the confirmation dialog (it looks like 123456789.apps.googleusercontent.com). You will use this as your serverClientId later.
Why do I need a Web client ID?
Credential Manager's GetSignInWithGoogleOption requires a serverClientId to request an ID token that your backend (or in this case, the Google APIs) can verify. Even though your app runs entirely on the device, this Web client ID is what makes the token exchange work.
Create the Android Studio project and add dependencies
With your Cloud project ready, it is time to create the Android app that will use these credentials. The package name you use here must match the one you registered in the OAuth Android client.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Android Studio and press Enter to open it.
- Click New Project.
- Select Empty Activity (this gives you a Compose-ready template) and click Next.
- Set the Name to Drive Sheets Navigator.
- Set the Package name to com.example.drivesheetsnavigator.
- Set Minimum SDK to API 26.
- Click Finish and wait for the project to sync.
Why API 26 minimum?
While Credential Manager supports Android 4.4+, this project targets API 26 (Android 8.0) to ensure full compatibility with modern Jetpack Compose features and lifecycle APIs.
Now add the dependencies your app needs for networking, authentication, and navigation.
- In the Project panel on the left, expand Gradle Scripts and open build.gradle.kts (Module :app).
- Add the following dependencies inside the dependencies { } block:
// Compose BOM - manages all Compose library versions
implementation(platform("androidx.compose:compose-bom:2026.06.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
// Navigation 3 - screen-to-screen navigation for Compose
implementation("androidx.navigation3:navigation3-runtime:1.1.3")
implementation("androidx.navigation3:navigation3-ui:1.1.3")
// Retrofit - HTTP client for calling Google APIs
implementation("com.squareup.retrofit2:retrofit:3.0.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:3.0.0")
// Credential Manager - Google Sign-In
implementation("androidx.credentials:credentials:1.6.0")
implementation("androidx.credentials:credentials-play-services-auth:1.6.0")
// Google ID library - provides GetSignInWithGoogleOption
implementation("com.google.android.libraries.identity.googleid:googleid:1.2.0")
What do these dependencies do?
- Compose BOM (2026.06.00) pins all Compose library versions together so they stay compatible.
- Navigation 3 (1.1.3) provides the NavDisplay API for Compose-native screen navigation.
- Retrofit (3.0.0) is a type-safe HTTP client for calling the Drive and Sheets REST endpoints.
- converter-kotlinx-serialization lets Retrofit parse JSON responses using Kotlin's built-in serialization.
- Credential Manager (1.6.0) handles the Google Sign-In flow on modern Android.
- Google ID library (1.2.0) provides GetSignInWithGoogleOption and GoogleIdTokenCredential for token-based auth.
- Also add the Kotlin serialization plugin at the top of the same file, inside the plugins { } block:
// Add this inside the plugins { } block
kotlin("plugin.serialization") version "2.2.21"
- Click Sync Now in the yellow bar that appears at the top of the editor.
- Wait for the sync to complete successfully. You should see BUILD SUCCESSFUL in the Build output at the bottom.
Sync failed or unresolved dependencies?
Make sure google() and mavenCentral() are listed in your project-level settings.gradle.kts under repositories. Android Studio's default template includes these, but double-check if you see resolution errors.
Help me troubleshoot my Gradle sync failure.
✔️ Awesome, I've got everything!
Great. Double check you have saved build.gradle.kts and that the Gradle sync succeeded with no errors.
ⓧ I'd like to double check the full code
Here is what your dependencies { } block and plugins { } addition should look like. Your file will have additional default dependencies from the template. Make sure the lines below are present:
plugins {
// ... existing plugins from the template
kotlin("plugin.serialization") version "2.2.21"
}
dependencies {
// ... existing template dependencies
// Compose BOM - manages all Compose library versions
implementation(platform("androidx.compose:compose-bom:2026.06.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
// Navigation 3 - screen-to-screen navigation for Compose
implementation("androidx.navigation3:navigation3-runtime:1.1.3")
implementation("androidx.navigation3:navigation3-ui:1.1.3")
// Retrofit - HTTP client for calling Google APIs
implementation("com.squareup.retrofit2:retrofit:3.0.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:3.0.0")
// Credential Manager - Google Sign-In
implementation("androidx.credentials:credentials:1.6.0")
implementation("androidx.credentials:credentials-play-services-auth:1.6.0")
// Google ID library - provides GetSignInWithGoogleOption
implementation("com.google.android.libraries.identity.googleid:googleid:1.2.0")
}
Your cloud project is configured and your Android app has all the libraries it needs. Next up, you will implement Google Sign-In with Credential Manager to get an access token for the Drive and Sheets APIs.
Implement Google Sign-In with Credential Manager
Your project compiles and all dependencies are in place. Now it's time to connect your app to Google so users can sign in and grant access to their Drive files.
Google's recommended architecture separates authentication (who is the user) from authorization (what can they access). You'll use Credential Manager to authenticate the user and obtain an ID token, then AuthorizationClient to request the drive.readonly scope and obtain an access token for API calls.
In this step, get ready to:
- Build an AuthManager class that handles both authentication and authorization.
- Create a SignInScreen composable with a Google Sign-In button.
- Wire up navigation so sign-in leads to the Drive tree screen.
Create the AuthManager class
The AuthManager encapsulates the two-phase auth flow. First it uses GetSignInWithGoogleOption with Credential Manager to get the user's identity. Then it calls AuthorizationClient to request permission to read their Drive.
- In Android Studio, right-click your main package folder and select New then Kotlin Class/File.
- Name it AuthManager and select Class.
- Replace the file contents with the following code:
package com.example.sheetnavigator
import android.content.Context
import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import com.google.android.gms.auth.api.identity.AuthorizationRequest
import com.google.android.gms.auth.api.identity.AuthorizationResult
import com.google.android.gms.auth.api.identity.Identity
import com.google.android.gms.common.api.Scope
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import kotlinx.coroutines.tasks.await
// Handles Google Sign-In (identity) and Drive authorization (access token)
class AuthManager(private val context: Context) {
// Your Web client ID from Google Cloud Console
companion object {
private const val WEB_CLIENT_ID = "your-web-client-id-here"
}
private val credentialManager = CredentialManager.create(context)
// Stores the user's display name after sign-in
var userName: String? = null
private set
// Stores the user's email after sign-in
var userEmail: String? = null
private set
// Stores the access token for Drive and Sheets API calls
var accessToken: String? = null
private set
// Phase 1: Authenticate the user and get their identity
suspend fun signIn(): Boolean {
val signInOption = GetSignInWithGoogleOption.Builder(
serverClientId = WEB_CLIENT_ID
).build()
val request = GetCredentialRequest.Builder()
.addCredentialOption(signInOption)
.build()
return try {
val result = credentialManager.getCredential(context, request)
val credential = result.credential
if (credential is CustomCredential &&
credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
) {
// Extract user info from the Google ID token
val googleCredential = GoogleIdTokenCredential.createFrom(credential.data)
userName = googleCredential.displayName
userEmail = googleCredential.id
true
} else {
false
}
} catch (e: Exception) {
e.printStackTrace()
false
}
}
// Phase 2: Request drive.readonly scope to get an access token
suspend fun authorize(activity: android.app.Activity): Boolean {
val requestedScopes = listOf(
Scope("https://www.googleapis.com/auth/drive.readonly")
)
val authorizationRequest = AuthorizationRequest.builder()
.setRequestedScopes(requestedScopes)
.build()
return try {
val authResult: AuthorizationResult = Identity
.getAuthorizationClient(activity)
.authorize(authorizationRequest)
.await()
if (!authResult.hasResolution()) {
// Access was granted without additional user interaction
accessToken = authResult.accessToken
true
} else {
// User needs to grant permission via a consent screen
// This will be handled by the calling composable
false
}
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}
What does this code do?
The AuthManager class handles two separate flows required by Google's architecture:
- signIn() uses Credential Manager with GetSignInWithGoogleOption to show the Google account picker and retrieve a Google ID token. This gives you the user's name and email.
- authorize() uses AuthorizationClient to request the drive.readonly scope. If consent was previously granted, it returns an access token immediately. Otherwise, it signals that a resolution (consent screen) is needed.
- The access token stored in accessToken is what you'll attach to every Drive and Sheets API request as a Bearer token.
Why two separate steps instead of one?
Google separates authentication from authorization so users only see permission prompts when they actually need a feature. The sign-in step confirms identity. The authorization step requests data access. This reduces user friction and follows Google's recommended best practices.
- Replace your-web-client-id-here in the WEB_CLIENT_ID constant with your actual Web application client ID from the Google Cloud Console.
- Save AuthManager.kt.
Can't find your Web client ID?
Return to the Google Cloud Console. Navigate to APIs & Services then Credentials. Look for the OAuth 2.0 Client ID with type Web application. Copy the Client ID value (it ends in .apps.googleusercontent.com).
help me find my Web client ID in Google Cloud Console
Build the SignInScreen composable
Now you need a screen that presents a sign-in button to the user. When tapped, it triggers the AuthManager flow and displays the user's name and email on success.
- Create a new Kotlin file called SignInScreen.kt in your main package folder.
- Add the following code:
package com.example.sheetnavigator
import android.app.Activity
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
// Sign-in screen with a Google Sign-In button
@Composable
fun SignInScreen(
authManager: AuthManager,
onSignInSuccess: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val activity = context as Activity
// Track UI state for the sign-in process
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Google Sheets Navigator",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Sign in to browse your Google Drive",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(48.dp))
// Show user info if already signed in
if (authManager.userName != null) {
Text(text = "Welcome, ${authManager.userName}")
Text(text = authManager.userEmail ?: "")
Spacer(modifier = Modifier.height(16.dp))
}
// Sign-in button
Button(
onClick = {
coroutineScope.launch {
isLoading = true
errorMessage = null
// Phase 1: Authenticate with Google
val signedIn = authManager.signIn()
if (signedIn) {
// Phase 2: Request Drive read access
val authorized = authManager.authorize(activity)
if (authorized) {
onSignInSuccess()
} else {
errorMessage = "Drive access not granted."
}
} else {
errorMessage = "Sign-in failed. Please try again."
}
isLoading = false
}
},
enabled = !isLoading
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
}
Text("Sign in with Google")
}
// Display error messages
errorMessage?.let { msg ->
Spacer(modifier = Modifier.height(16.dp))
Text(
text = msg,
color = MaterialTheme.colorScheme.error
)
}
}
}
What does this composable do?
- It displays a "Sign in with Google" button that the user taps to start the auth flow.
- When tapped, it calls authManager.signIn() first (authentication), then authManager.authorize(activity) (authorization).
- On success, it calls onSignInSuccess() which triggers navigation to the Drive tree screen.
- It shows a loading spinner while the flow is in progress and an error message if something fails.
- After sign-in, the user's name and email are displayed on screen.
- Save SignInScreen.kt.
Wire up navigation with NavHost
The final piece connects SignInScreen as the app's start destination and navigates to the Drive tree screen after successful authentication.
- Open your MainActivity.kt file.
- Replace the content of your setContent block with the following navigation setup:
package com.example.sheetnavigator
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.sheetnavigator.ui.theme.SheetNavigatorTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SheetNavigatorTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
// Create a shared AuthManager instance
val authManager = remember { AuthManager(this@MainActivity) }
val navController = rememberNavController()
// NavHost with sign-in as the start destination
NavHost(
navController = navController,
startDestination = "sign_in"
) {
// Sign-in screen: first screen the user sees
composable("sign_in") {
SignInScreen(
authManager = authManager,
onSignInSuccess = {
navController.navigate("drive_tree") {
// Remove sign-in from the back stack
popUpTo("sign_in") { inclusive = true }
}
}
)
}
// Placeholder for the Drive tree screen (built in Step 3)
composable("drive_tree") {
Text("Drive Tree Screen - Coming in Step 3")
}
}
}
}
}
}
}
What does this navigation setup do?
- NavHost manages the navigation graph with sign_in as the start destination.
- When onSignInSuccess fires, it navigates to drive_tree and removes the sign-in screen from the back stack so the user can't navigate back to it.
- The AuthManager instance is created with remember so it persists across recompositions.
- The drive_tree composable is a placeholder that you'll replace in the next step.
- Save MainActivity.kt.
- Build the project by pressing Ctrl+F9 (Windows) or Cmd+F9 (macOS). The project should compile without errors.
Seeing unresolved reference errors?
Make sure your build.gradle.kts includes the Credential Manager, Google ID, and Navigation Compose dependencies from Step 1. If you added them recently, click Sync Now in the banner at the top of the editor.
help me resolve import errors in my AuthManager or MainActivity
Now run the app on your emulator or device to test the sign-in flow.
- Click the green Run button (or press Shift+F10 on Windows / Ctrl+R on macOS).
- Tap Sign in with Google.
- The Google account picker appears. Select your test account (the one you added in the OAuth consent screen's test users).
- Grant the drive.readonly permission when the consent screen appears.
- You should see the placeholder text "Drive Tree Screen - Coming in Step 3" after successful authentication.
Sign-in fails or no account picker appears?
- Verify your emulator or device has a Google account signed in and that account is listed as a test user in your OAuth consent screen.
- Check that your WEB_CLIENT_ID is the Web application client ID (not the Android client ID).
- Confirm your app's package name and SHA-1 fingerprint in the Android OAuth client match your debug build exactly.
help me debug why Google Sign-In isn't working on my Android emulator
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file at this point:
AuthManager.kt:
package com.example.sheetnavigator
import android.content.Context
import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import com.google.android.gms.auth.api.identity.AuthorizationRequest
import com.google.android.gms.auth.api.identity.AuthorizationResult
import com.google.android.gms.auth.api.identity.Identity
import com.google.android.gms.common.api.Scope
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import kotlinx.coroutines.tasks.await
// Handles Google Sign-In (identity) and Drive authorization (access token)
class AuthManager(private val context: Context) {
// Your Web client ID from Google Cloud Console
companion object {
private const val WEB_CLIENT_ID = "your-web-client-id-here"
}
private val credentialManager = CredentialManager.create(context)
// Stores the user's display name after sign-in
var userName: String? = null
private set
// Stores the user's email after sign-in
var userEmail: String? = null
private set
// Stores the access token for Drive and Sheets API calls
var accessToken: String? = null
private set
// Phase 1: Authenticate the user and get their identity
suspend fun signIn(): Boolean {
val signInOption = GetSignInWithGoogleOption.Builder(
serverClientId = WEB_CLIENT_ID
).build()
val request = GetCredentialRequest.Builder()
.addCredentialOption(signInOption)
.build()
return try {
val result = credentialManager.getCredential(context, request)
val credential = result.credential
if (credential is CustomCredential &&
credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
) {
// Extract user info from the Google ID token
val googleCredential = GoogleIdTokenCredential.createFrom(credential.data)
userName = googleCredential.displayName
userEmail = googleCredential.id
true
} else {
false
}
} catch (e: Exception) {
e.printStackTrace()
false
}
}
// Phase 2: Request drive.readonly scope to get an access token
suspend fun authorize(activity: android.app.Activity): Boolean {
val requestedScopes = listOf(
Scope("https://www.googleapis.com/auth/drive.readonly")
)
val authorizationRequest = AuthorizationRequest.builder()
.setRequestedScopes(requestedScopes)
.build()
return try {
val authResult: AuthorizationResult = Identity
.getAuthorizationClient(activity)
.authorize(authorizationRequest)
.await()
if (!authResult.hasResolution()) {
// Access was granted without additional user interaction
accessToken = authResult.accessToken
true
} else {
// User needs to grant permission via a consent screen
// This will be handled by the calling composable
false
}
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}
SignInScreen.kt:
package com.example.sheetnavigator
import android.app.Activity
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
// Sign-in screen with a Google Sign-In button
@Composable
fun SignInScreen(
authManager: AuthManager,
onSignInSuccess: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val activity = context as Activity
// Track UI state for the sign-in process
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Google Sheets Navigator",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Sign in to browse your Google Drive",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(48.dp))
// Show user info if already signed in
if (authManager.userName != null) {
Text(text = "Welcome, ${authManager.userName}")
Text(text = authManager.userEmail ?: "")
Spacer(modifier = Modifier.height(16.dp))
}
// Sign-in button
Button(
onClick = {
coroutineScope.launch {
isLoading = true
errorMessage = null
// Phase 1: Authenticate with Google
val signedIn = authManager.signIn()
if (signedIn) {
// Phase 2: Request Drive read access
val authorized = authManager.authorize(activity)
if (authorized) {
onSignInSuccess()
} else {
errorMessage = "Drive access not granted."
}
} else {
errorMessage = "Sign-in failed. Please try again."
}
isLoading = false
}
},
enabled = !isLoading
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
}
Text("Sign in with Google")
}
// Display error messages
errorMessage?.let { msg ->
Spacer(modifier = Modifier.height(16.dp))
Text(
text = msg,
color = MaterialTheme.colorScheme.error
)
}
}
}
MainActivity.kt:
package com.example.sheetnavigator
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.sheetnavigator.ui.theme.SheetNavigatorTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SheetNavigatorTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
// Create a shared AuthManager instance
val authManager = remember { AuthManager(this@MainActivity) }
val navController = rememberNavController()
// NavHost with sign-in as the start destination
NavHost(
navController = navController,
startDestination = "sign_in"
) {
// Sign-in screen: first screen the user sees
composable("sign_in") {
SignInScreen(
authManager = authManager,
onSignInSuccess = {
navController.navigate("drive_tree") {
// Remove sign-in from the back stack
popUpTo("sign_in") { inclusive = true }
}
}
)
}
// Placeholder for the Drive tree screen (built in Step 3)
composable("drive_tree") {
Text("Drive Tree Screen - Coming in Step 3")
}
}
}
}
}
}
}
Your app now authenticates users with Google and obtains an access token for Drive. Next up, you'll use that token to fetch the user's folders and Google Sheets and display them as an expandable tree.
Build the Drive Tree Browser
Your app can now sign in with Google and obtain an access token. The next challenge is actually using that token to read data from your Google Drive.
In this step, you will call the Google Drive API to fetch your folders and Google Sheets, assemble them into a tree structure, prune out empty folders, and display the result as an expandable directory the user can browse.
In this step, get ready to:
- Create a Retrofit interface for the Drive API files.list endpoint.
- Build a tree data structure that groups files by parent and prunes empty folders.
- Display the tree as an expandable list of folders and clickable sheet cards.
Create the Drive API service and repository
The Drive API v3 exposes a files.list endpoint that returns file metadata based on query filters. You need a Retrofit interface to define this HTTP call, plus data classes to parse the JSON response.
- Create a new file called DriveApiService.kt in your project. This file defines the data models and the Retrofit interface for the Drive API.
- Add the following code to DriveApiService.kt:
package com.example.sheetsnav.api
import kotlinx.serialization.Serializable
import okhttp3.MediaType.Companion.toMediaType
import kotlinx.serialization.json.Json
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Query
// Data model for a single Drive file or folder
@Serializable
data class DriveFile(
val id: String,
val name: String,
val mimeType: String,
val parents: List<String>? = null
)
// Response wrapper for the files.list endpoint
@Serializable
data class DriveFileListResponse(
val files: List<DriveFile> = emptyList(),
val nextPageToken: String? = null
)
// Retrofit interface for Google Drive API v3
interface DriveApiService {
@GET("drive/v3/files")
suspend fun listFiles(
@Header("Authorization") authHeader: String,
@Query("q") q: String,
@Query("fields") fields: String,
@Query("pageToken") pageToken: String? = null,
@Query("spaces") spaces: String = "drive"
): DriveFileListResponse
}
// Singleton Retrofit instance for Drive API calls
object DriveRetrofitClient {
private val json = Json { ignoreUnknownKeys = true }
val instance: DriveApiService by lazy {
Retrofit.Builder()
.baseUrl("https://www.googleapis.com/")
.addConverterFactory(
json.asConverterFactory("application/json; charset=utf-8".toMediaType())
)
.build()
.create(DriveApiService::class.java)
}
}
What does this code do?
The DriveFile data class maps to the JSON structure the Drive API returns for each file. The parents field is nullable because root-level files may not have a parent in your response.
The DriveApiService interface defines a single suspend function matching the GET https://www.googleapis.com/drive/v3/files endpoint. Retrofit 3.0.0 supports suspend functions natively, so no call adapter is needed.
The DriveRetrofitClient object creates the Retrofit instance with a kotlinx.serialization converter. The ignoreUnknownKeys = true setting prevents crashes if Google adds new fields to the response in the future.
Now create the repository that uses this service. The repository handles pagination and assembles the raw file list.
- Create a new file called DriveRepository.kt.
- Add the fetch function that pages through all Drive results by running this code:
package com.example.sheetsnav.repository
import com.example.sheetsnav.api.DriveApiService
import com.example.sheetsnav.api.DriveFile
// Represents a node in the Drive folder tree
data class DriveTreeNode(
val file: DriveFile,
val children: MutableList<DriveTreeNode> = mutableListOf()
)
class DriveRepository(private val apiService: DriveApiService) {
// Fetch all folders and Google Sheets from Drive, handling pagination
suspend fun fetchAllDriveFiles(accessToken: String): List<DriveFile> {
val allFiles = mutableListOf<DriveFile>()
var pageToken: String? = null
// Query for folders and spreadsheets only, excluding trashed files
val query = "(mimeType = 'application/vnd.google-apps.folder' or " +
"mimeType = 'application/vnd.google-apps.spreadsheet') and trashed = false"
val fields = "nextPageToken,files(id,name,mimeType,parents)"
do {
val response = apiService.listFiles(
authHeader = "Bearer $accessToken",
q = query,
fields = fields,
pageToken = pageToken
)
allFiles.addAll(response.files)
pageToken = response.nextPageToken
} while (pageToken != null)
return allFiles
}
}
What does this code do?
The fetchAllDriveFiles function queries Drive for all items matching two MIME types: application/vnd.google-apps.folder (folders) and application/vnd.google-apps.spreadsheet (Google Sheets).
The fields parameter tells the API to return only the id, name, mimeType, and parents for each file. This minimizes network transfer.
The do-while loop handles pagination. Drive returns at most 100 files per page. If nextPageToken is present, another page exists and the loop continues.
- Save DriveApiService.kt and DriveRepository.kt.
- Confirm the project compiles without errors. You should see no red underlines in either file.
Seeing unresolved reference errors?
Make sure your build.gradle.kts includes the Retrofit dependency (com.squareup.retrofit2:retrofit:3.0.0) and the serialization converter (com.squareup.retrofit2:converter-kotlinx-serialization:3.0.0). Sync Gradle after any changes.
If toMediaType() is unresolved, confirm the OkHttp dependency is present. Retrofit 3.0.0 bundles OkHttp 4.12.0 transitively, but the import is okhttp3.MediaType.Companion.toMediaType.
Help me fix unresolved references in my DriveApiService.kt
Build the tree data structure with pruning
The Drive API returns a flat list of files. Each file has a parents array containing the ID of its parent folder. Your job is to assemble this flat list into a tree, then remove any folder that contains no Google Sheets anywhere in its subtree.
- In DriveRepository.kt, add the following two functions inside the DriveRepository class, below the fetchAllDriveFiles function:
// Build tree structure by grouping files under their parent folders
fun buildTree(files: List<DriveFile>): List<DriveTreeNode> {
val nodeMap = files.associateBy({ it.id }, { DriveTreeNode(file = it) })
val roots = mutableListOf<DriveTreeNode>()
for (node in nodeMap.values) {
val parentId = node.file.parents?.firstOrNull()
if (parentId != null && nodeMap.containsKey(parentId)) {
// Attach to parent node
nodeMap[parentId]!!.children.add(node)
} else {
// No known parent in our set, treat as root
roots.add(node)
}
}
return roots
}
// Recursively prune folders that contain no Google Sheets
fun pruneTree(nodes: List<DriveTreeNode>): List<DriveTreeNode> {
return nodes.mapNotNull { node -> pruneNode(node) }
}
private fun pruneNode(node: DriveTreeNode): DriveTreeNode? {
// Spreadsheets are always kept as leaf nodes
if (node.file.mimeType == "application/vnd.google-apps.spreadsheet") {
return node
}
// For folders, recursively prune children first
val prunedChildren = node.children.mapNotNull { pruneNode(it) }
// Keep this folder only if it has at least one surviving child
return if (prunedChildren.isNotEmpty()) {
DriveTreeNode(file = node.file, children = prunedChildren.toMutableList())
} else {
null
}
}
What does this code do?
The buildTree function creates a map of all files by their ID. It then iterates through each file and attaches it to its parent node. Files whose parent is not in the fetched set become root nodes.
The pruneTree function recursively walks the tree. A folder survives only if at least one of its descendants is a Google Sheet. This filtering happens client-side because the Drive API cannot express "show only folders that recursively contain spreadsheets" in a single query.
Why build the tree client-side?
The Drive API can filter by MIME type, but it cannot filter by hierarchical containment. It has no way to say "only return folders that, somewhere in their subtree, contain a spreadsheet." The only option is to fetch all folders and spreadsheets in one pass, then build and prune the tree locally.
- Save DriveRepository.kt.
- Confirm the file compiles. You will not see a visual change yet. The next substep connects this logic to the UI.
✔️ Awesome, I've got everything!
Great. Double check you have saved DriveRepository.kt with all three functions: fetchAllDriveFiles, buildTree, and pruneTree.
ⓧ I'd like to double check the full code
package com.example.sheetsnav.repository
import com.example.sheetsnav.api.DriveApiService
import com.example.sheetsnav.api.DriveFile
// Represents a node in the Drive folder tree
data class DriveTreeNode(
val file: DriveFile,
val children: MutableList<DriveTreeNode> = mutableListOf()
)
class DriveRepository(private val apiService: DriveApiService) {
// Fetch all folders and Google Sheets from Drive, handling pagination
suspend fun fetchAllDriveFiles(accessToken: String): List<DriveFile> {
val allFiles = mutableListOf<DriveFile>()
var pageToken: String? = null
// Query for folders and spreadsheets only, excluding trashed files
val query = "(mimeType = 'application/vnd.google-apps.folder' or " +
"mimeType = 'application/vnd.google-apps.spreadsheet') and trashed = false"
val fields = "nextPageToken,files(id,name,mimeType,parents)"
do {
val response = apiService.listFiles(
authHeader = "Bearer $accessToken",
q = query,
fields = fields,
pageToken = pageToken
)
allFiles.addAll(response.files)
pageToken = response.nextPageToken
} while (pageToken != null)
return allFiles
}
// Build tree structure by grouping files under their parent folders
fun buildTree(files: List<DriveFile>): List<DriveTreeNode> {
val nodeMap = files.associateBy({ it.id }, { DriveTreeNode(file = it) })
val roots = mutableListOf<DriveTreeNode>()
for (node in nodeMap.values) {
val parentId = node.file.parents?.firstOrNull()
if (parentId != null && nodeMap.containsKey(parentId)) {
// Attach to parent node
nodeMap[parentId]!!.children.add(node)
} else {
// No known parent in our set, treat as root
roots.add(node)
}
}
return roots
}
// Recursively prune folders that contain no Google Sheets
fun pruneTree(nodes: List<DriveTreeNode>): List<DriveTreeNode> {
return nodes.mapNotNull { node -> pruneNode(node) }
}
private fun pruneNode(node: DriveTreeNode): DriveTreeNode? {
// Spreadsheets are always kept as leaf nodes
if (node.file.mimeType == "application/vnd.google-apps.spreadsheet") {
return node
}
// For folders, recursively prune children first
val prunedChildren = node.children.mapNotNull { pruneNode(it) }
// Keep this folder only if it has at least one surviving child
return if (prunedChildren.isNotEmpty()) {
DriveTreeNode(file = node.file, children = prunedChildren.toMutableList())
} else {
null
}
}
}
Create the Drive tree screen
The tree data structure is ready. Now you need a composable screen that renders folders as expandable nodes and Google Sheets as tappable cards.
- Create a new file called DriveTreeScreen.kt and add the following composable:
package com.example.sheetsnav.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.sheetsnav.repository.DriveTreeNode
@Composable
fun DriveTreeScreen(
treeNodes: List<DriveTreeNode>,
isLoading: Boolean,
onSheetClick: (spreadsheetId: String, name: String) -> Unit
) {
// Show a loading indicator while fetching Drive data
if (isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
// Render the tree as a scrollable list
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
items(treeNodes) { node ->
TreeNodeItem(
node = node,
depth = 0,
onSheetClick = onSheetClick
)
}
}
}
}
@Composable
fun TreeNodeItem(
node: DriveTreeNode,
depth: Int,
onSheetClick: (spreadsheetId: String, name: String) -> Unit
) {
val isFolder = node.file.mimeType == "application/vnd.google-apps.folder"
var expanded by remember { mutableStateOf(false) }
Column(modifier = Modifier.padding(start = (depth * 16).dp)) {
if (isFolder) {
// Folder row: tap to expand or collapse
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (expanded) "\uD83D\uDCC2" else "\uD83D\uDCC1",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = node.file.name,
style = MaterialTheme.typography.bodyLarge
)
}
// Render children when expanded
if (expanded) {
node.children.forEach { child ->
TreeNodeItem(
node = child,
depth = depth + 1,
onSheetClick = onSheetClick
)
}
}
} else {
// Spreadsheet card: tap to navigate
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onSheetClick(node.file.id, node.file.name) },
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "\uD83D\uDCCA",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = node.file.name,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
What does this code do?
The DriveTreeScreen composable shows a loading spinner while data is being fetched. Once loaded, it renders each root node using a LazyColumn.
The TreeNodeItem composable is recursive. Folders render as a clickable row that toggles an expanded state. When expanded, the folder's children are rendered below it, indented by depth. Spreadsheets render as Material 3 cards that call onSheetClick with the file's ID and name.
The last piece is wiring the screen into your navigation graph so tapping a sheet card navigates to the Sheet Tabs screen.
- In your NavHost file, find the composable route for DriveTreeScreen.
- Update the onSheetClick callback to navigate to SheetTabsScreen, passing the spreadsheet ID and name as navigation arguments.
Why pass both ID and name?
The spreadsheet ID is needed to call the Sheets API in the next step. The name is passed so the Sheet Tabs screen can display a meaningful title without making an extra API call.
- Save all files.
- Run the app on your emulator or device. After signing in, you should see your Drive folder tree with only folders that contain Google Sheets. Empty folders are hidden.
- Tap a folder to expand it. Tap a Google Sheet card to confirm it triggers navigation (the destination screen does not exist yet, so you may see an error or blank screen).
Tree is empty or not loading?
Make sure your Google account has at least one Google Sheet stored in Drive. If your Drive is empty, create a test spreadsheet at sheets.google.com and try again.
If you see a network error, confirm that your access token is being passed correctly. The Authorization header must be formatted as Bearer <token> with a space between the word Bearer and the token value.
Help me debug my empty Drive tree screen
Your app now reads your entire Google Drive, builds a filtered tree, and displays it as a browsable directory. Next up, you will load the individual tabs inside a selected spreadsheet.
Build the Sheet Tabs Screen
In the previous step, you built a Drive tree browser that shows only folders containing Google Sheets. Now it is time to let the user tap a sheet and see what is inside it.
Each Google Sheet can contain multiple tabs (the small labels at the bottom of the spreadsheet). In this step, you will call the Google Sheets API v4 to retrieve those tab names and display them as tappable cards.
In this step, get ready to:
- Create a Retrofit interface for the Sheets API spreadsheets.get endpoint.
- Build a SheetsRepository that fetches tab metadata for any spreadsheet.
- Display tab names as Material 3 cards in a grid and navigate to the Columns screen.
Create the Sheets API Retrofit interface
The Sheets API lives at a different base URL than the Drive API you used earlier. You need a dedicated Retrofit interface pointing at https://sheets.googleapis.com/.
- Create a new file called SheetsApiService.kt in the same package as your DriveApiService.kt.
- Add the following code to define response data classes and the Retrofit interface:
package com.example.drivesheetsnavigator.api
import kotlinx.serialization.Serializable
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Path
import retrofit2.http.Query
// Response shape for spreadsheets.get with fields=sheets.properties
@Serializable
data class SpreadsheetResponse(
val sheets: List<SheetEntry> = emptyList()
)
@Serializable
data class SheetEntry(
val properties: SheetProperties
)
@Serializable
data class SheetProperties(
val sheetId: Int,
val title: String
)
// Retrofit interface for Google Sheets API v4
interface SheetsApiService {
@GET("v4/spreadsheets/{spreadsheetId}")
suspend fun getSpreadsheet(
@Header("Authorization") authHeader: String,
@Path("spreadsheetId") spreadsheetId: String,
@Query("fields") fields: String = "sheets.properties"
): SpreadsheetResponse
}
What does this code do?
The @GET("v4/spreadsheets/{spreadsheetId}") annotation maps to the official endpoint GET https://sheets.googleapis.com/v4/spreadsheets/{spreadsheetId}. The fields query parameter uses a field mask to request only sheets.properties, which returns the tab names and IDs without downloading any cell data.
The three @Serializable data classes model the JSON response: a list of sheets, each containing properties with a sheetId and title.
- Save SheetsApiService.kt.
- Confirm there are no red underlines in the editor. The file should compile without errors since it uses the same Retrofit and kotlinx.serialization dependencies you added earlier.
Seeing unresolved references?
Make sure kotlinx.serialization and retrofit2 imports resolve correctly. These dependencies were added to build.gradle.kts in Step 1. If they are missing, sync your Gradle files.
help me fix unresolved imports in SheetsApiService.kt
Implement the SheetsRepository
The repository wraps the raw API call and returns a clean list of tab properties to the UI layer.
- Create a new file called SheetsRepository.kt alongside your DriveRepository.kt.
- Add the following code:
package com.example.drivesheetsnavigator.repository
import com.example.drivesheetsnavigator.api.SheetProperties
import com.example.drivesheetsnavigator.api.SheetsApiService
class SheetsRepository(
private val sheetsApiService: SheetsApiService
) {
// Fetches tab metadata for a given spreadsheet ID
suspend fun getSheetTabs(
accessToken: String,
spreadsheetId: String
): List<SheetProperties> {
val response = sheetsApiService.getSpreadsheet(
authHeader = "Bearer $accessToken",
spreadsheetId = spreadsheetId,
fields = "sheets.properties"
)
return response.sheets.map { it.properties }
}
}
What does this code do?
The getSheetTabs function takes the user's access token and a spreadsheet ID, calls the Sheets API with the sheets.properties field mask, and returns a flat list of SheetProperties (tab name and ID) for the UI to display.
The drive.readonly scope you already granted during sign-in covers reading spreadsheet metadata, so no additional permissions are needed.
- Save SheetsRepository.kt.
- Verify there are no compile errors. The file depends only on SheetsApiService and SheetProperties, both defined in the previous chunk.
Cannot resolve SheetProperties import?
Double-check that the package declaration in SheetsApiService.kt matches the import path in SheetsRepository.kt. Both files must share a consistent package structure.
help me resolve import issues between SheetsRepository and SheetsApiService
Build the SheetTabsScreen composable
This screen receives the spreadsheet ID from the Drive tree navigation, fetches the tabs, and renders each tab name as a clickable card.
- Create a new file called SheetTabsScreen.kt in your UI package.
- Add the following composable:
package com.example.drivesheetsnavigator.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.drivesheetsnavigator.api.SheetProperties
import com.example.drivesheetsnavigator.repository.SheetsRepository
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SheetTabsScreen(
spreadsheetId: String,
spreadsheetName: String,
accessToken: String,
sheetsRepository: SheetsRepository,
onTabClick: (String) -> Unit
) {
var tabs by remember { mutableStateOf<List<SheetProperties>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
val scope = rememberCoroutineScope()
// Load tabs when screen appears
LaunchedEffect(spreadsheetId) {
scope.launch {
tabs = sheetsRepository.getSheetTabs(accessToken, spreadsheetId)
isLoading = false
}
}
Scaffold(
topBar = {
TopAppBar(title = { Text(spreadsheetName) })
}
) { padding ->
if (isLoading) {
// Show loading indicator while fetching tabs
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
// Display tabs as Material 3 cards in a grid
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(tabs) { tab ->
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onTabClick(tab.title) }
) {
Text(
text = tab.title,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium
)
}
}
}
}
}
}
What does this code do?
The LaunchedEffect triggers the API call as soon as the screen appears, loading the spreadsheet's tab metadata from SheetsRepository.
While loading, a CircularProgressIndicator keeps the user informed. Once loaded, LazyVerticalGrid with GridCells.Fixed(2) arranges tab cards in a two-column grid. Each card shows the tab's title and calls onTabClick with the tab name when tapped, which triggers navigation to the Columns screen.
- Save SheetTabsScreen.kt.
- Next, wire this screen into your NavHost so tapping a sheet in the Drive tree actually opens it.
- Return to your NavHost file (MainActivity.kt or NavGraph.kt) and add a new composable destination for SheetTabsScreen that receives spreadsheetId and spreadsheetName as navigation arguments.
- In the onTabClick callback, navigate to a ColumnsScreen route, passing the spreadsheetId and selected tab name.
Why pass the tab name as a navigation argument?
The Columns screen needs both the spreadsheet ID and the exact tab name to call spreadsheets.values.get with the correct range. Passing both as route parameters keeps each screen independent and stateless.
- Also create a Retrofit instance for the Sheets API with the base URL https://sheets.googleapis.com/ and wire it into SheetsRepository. This follows the same pattern you used for DriveApiService with the Drive base URL.
- Build the project to confirm everything compiles.
Navigation route not resolving?
Make sure your route string for SheetTabsScreen includes both arguments as path or query parameters, and that you extract them with backStackEntry.arguments inside the composable lambda. URL-encode any argument that might contain special characters.
help me fix navigation arguments for SheetTabsScreen in my NavHost
- Run the app on your emulator or device.
- Sign in, expand a folder in the Drive tree, and tap a Google Sheet.
- You should see the SheetTabsScreen appear with tab names displayed as cards in a two-column grid.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three new files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each new file created in this step:
package com.example.drivesheetsnavigator.api
import kotlinx.serialization.Serializable
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Path
import retrofit2.http.Query
// Response shape for spreadsheets.get with fields=sheets.properties
@Serializable
data class SpreadsheetResponse(
val sheets: List<SheetEntry> = emptyList()
)
@Serializable
data class SheetEntry(
val properties: SheetProperties
)
@Serializable
data class SheetProperties(
val sheetId: Int,
val title: String
)
// Retrofit interface for Google Sheets API v4
interface SheetsApiService {
@GET("v4/spreadsheets/{spreadsheetId}")
suspend fun getSpreadsheet(
@Header("Authorization") authHeader: String,
@Path("spreadsheetId") spreadsheetId: String,
@Query("fields") fields: String = "sheets.properties"
): SpreadsheetResponse
}
package com.example.drivesheetsnavigator.repository
import com.example.drivesheetsnavigator.api.SheetProperties
import com.example.drivesheetsnavigator.api.SheetsApiService
class SheetsRepository(
private val sheetsApiService: SheetsApiService
) {
// Fetches tab metadata for a given spreadsheet ID
suspend fun getSheetTabs(
accessToken: String,
spreadsheetId: String
): List<SheetProperties> {
val response = sheetsApiService.getSpreadsheet(
authHeader = "Bearer $accessToken",
spreadsheetId = spreadsheetId,
fields = "sheets.properties"
)
return response.sheets.map { it.properties }
}
}
package com.example.drivesheetsnavigator.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.drivesheetsnavigator.api.SheetProperties
import com.example.drivesheetsnavigator.repository.SheetsRepository
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SheetTabsScreen(
spreadsheetId: String,
spreadsheetName: String,
accessToken: String,
sheetsRepository: SheetsRepository,
onTabClick: (String) -> Unit
) {
var tabs by remember { mutableStateOf<List<SheetProperties>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
val scope = rememberCoroutineScope()
// Load tabs when screen appears
LaunchedEffect(spreadsheetId) {
scope.launch {
tabs = sheetsRepository.getSheetTabs(accessToken, spreadsheetId)
isLoading = false
}
}
Scaffold(
topBar = {
TopAppBar(title = { Text(spreadsheetName) })
}
) { padding ->
if (isLoading) {
// Show loading indicator while fetching tabs
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
// Display tabs as Material 3 cards in a grid
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(tabs) { tab ->
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onTabClick(tab.title) }
) {
Text(
text = tab.title,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium
)
}
}
}
}
}
}
Your app now loads a spreadsheet's tabs from the Sheets API and displays them as a card grid. Next up, you will build the Columns screen so tapping a tab card reveals its column headers.
Build the Column Headers Screen
In the previous step, you built the Sheet Tabs screen that displays each tab inside a spreadsheet as a card. Now it is time to complete the final screen in your navigation flow.
The Columns screen fetches the first row of a selected tab and displays each column header as a tappable card. This gives you a full drill-down path from your Drive tree all the way to individual spreadsheet columns.
In this step, get ready to:
- Add a column headers endpoint to your Sheets API layer.
- Build a ColumnsScreen composable with a card grid and Toast feedback.
- Handle loading, empty, and error states gracefully.
Add the column headers endpoint
The Sheets API v4 exposes a spreadsheets.values.get endpoint that returns cell values for a given range. You will call it with the range {tabName}!1:1 to fetch only the first row of a tab.
- Switch back to SheetsApiService.kt and add a response model for the values endpoint, plus a new function to call it:
// Data class representing the response from spreadsheets.values.get
@Serializable
data class ValuesResponse(
val range: String = "",
val majorDimension: String = "",
val values: List<List<String>>? = null
)
// Add this function inside your SheetsApiService interface
@GET("v4/spreadsheets/{spreadsheetId}/values/{range}")
suspend fun getValues(
@Path("spreadsheetId") spreadsheetId: String,
@Path("range") range: String
): ValuesResponse
What does this code do?
The ValuesResponse data class models the JSON response from the Sheets API values endpoint. The values field is a list of rows, where each row is a list of cell strings.
The getValues() function maps to the REST endpoint GET https://sheets.googleapis.com/v4/spreadsheets/{spreadsheetId}/values/{range}. Retrofit substitutes both path parameters at runtime.
- Save SheetsApiService.kt.
Seeing an unresolved reference?
Make sure ValuesResponse is defined in the same file or is imported correctly if you placed it in a separate models file.
Check that your @Serializable import comes from kotlinx.serialization.Serializable.
help me fix my SheetsApiService compilation errors
Next, you need a repository function that calls this endpoint with the correct range string.
- Open SheetsRepository.kt and add the following function below your existing getSheetTabs() function:
// Fetches the first row (column headers) of a specific tab
suspend fun getColumnHeaders(spreadsheetId: String, tabName: String): List<String> {
// Build the A1 notation range for the first row of the tab
val range = "${tabName}!1:1"
val response = sheetsApiService.getValues(spreadsheetId, range)
// Return the first row's values, or empty list if tab has no data
return response.values?.firstOrNull() ?: emptyList()
}
What does this code do?
The getColumnHeaders() function builds an A1 notation range like Sheet1!1:1 that targets only the first row of the named tab.
If the API returns rows, firstOrNull() grabs the first (and only) row. If the tab is completely empty, the function returns an empty list.
- Save SheetsRepository.kt.
- Build the project to confirm there are no compilation errors.
Build failing after adding getColumnHeaders?
Ensure the sheetsApiService property in your SheetsRepository class has the getValues() function available. If you placed ValuesResponse in a different file, add the import.
help me fix my SheetsRepository compilation errors
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before continuing.
ⓧ I'd like to double check the full code
Here is the updated SheetsApiService.kt with both endpoints:
import kotlinx.serialization.Serializable
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
// Response model for spreadsheets.get (tab metadata)
@Serializable
data class SpreadsheetResponse(
val sheets: List<SheetEntry> = emptyList()
)
@Serializable
data class SheetEntry(
val properties: SheetProperties = SheetProperties()
)
@Serializable
data class SheetProperties(
val sheetId: Int = 0,
val title: String = ""
)
// Response model for spreadsheets.values.get (cell values)
@Serializable
data class ValuesResponse(
val range: String = "",
val majorDimension: String = "",
val values: List<List<String>>? = null
)
interface SheetsApiService {
// Get spreadsheet metadata (tab names)
@GET("v4/spreadsheets/{spreadsheetId}")
suspend fun getSpreadsheet(
@Path("spreadsheetId") spreadsheetId: String,
@Query("fields") fields: String = "sheets.properties"
): SpreadsheetResponse
// Get cell values for a specific range
@GET("v4/spreadsheets/{spreadsheetId}/values/{range}")
suspend fun getValues(
@Path("spreadsheetId") spreadsheetId: String,
@Path("range") range: String
): ValuesResponse
}
And here is the updated SheetsRepository.kt:
class SheetsRepository(
private val sheetsApiService: SheetsApiService
) {
// Fetches tab metadata for a spreadsheet
suspend fun getSheetTabs(spreadsheetId: String): List<SheetProperties> {
val response = sheetsApiService.getSpreadsheet(spreadsheetId)
return response.sheets.map { it.properties }
}
// Fetches the first row (column headers) of a specific tab
suspend fun getColumnHeaders(spreadsheetId: String, tabName: String): List<String> {
val range = "${tabName}!1:1"
val response = sheetsApiService.getValues(spreadsheetId, range)
return response.values?.firstOrNull() ?: emptyList()
}
}
Build the ColumnsScreen composable
The ColumnsScreen is the final destination in your navigation flow. It fetches column headers from the repository and renders them as a grid of tappable Material 3 cards.
- Create a new file called ColumnsScreen.kt in the same package as your other screen files and add the following code:
package com.example.sheetnavigator
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
// Sealed class representing the possible UI states for this screen
sealed class ColumnsUiState {
object Loading : ColumnsUiState()
data class Success(val headers: List<String>) : ColumnsUiState()
data class Error(val message: String) : ColumnsUiState()
object Empty : ColumnsUiState()
}
@Composable
fun ColumnsScreen(
spreadsheetId: String,
tabName: String,
sheetsRepository: SheetsRepository
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var uiState by remember { mutableStateOf<ColumnsUiState>(ColumnsUiState.Loading) }
// Fetch column headers when the screen loads
LaunchedEffect(spreadsheetId, tabName) {
uiState = ColumnsUiState.Loading
try {
val headers = sheetsRepository.getColumnHeaders(spreadsheetId, tabName)
uiState = if (headers.isEmpty()) {
ColumnsUiState.Empty
} else {
ColumnsUiState.Success(headers)
}
} catch (e: Exception) {
uiState = ColumnsUiState.Error("Failed to load columns: ${e.message}")
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// Screen title showing which tab the user selected
Text(
text = "Columns in $tabName",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp)
)
when (val state = uiState) {
// Show a spinner while data is loading
is ColumnsUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
// Show a message when the tab has no column headers
is ColumnsUiState.Empty -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "This tab has no column headers.",
style = MaterialTheme.typography.bodyLarge
)
}
}
// Show an error message with a retry button
is ColumnsUiState.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = state.message,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(bottom = 8.dp)
)
Button(onClick = {
// Retry fetching column headers
uiState = ColumnsUiState.Loading
coroutineScope.launch {
try {
val headers = sheetsRepository.getColumnHeaders(
spreadsheetId, tabName
)
uiState = if (headers.isEmpty()) {
ColumnsUiState.Empty
} else {
ColumnsUiState.Success(headers)
}
} catch (e: Exception) {
uiState = ColumnsUiState.Error(
"Failed to load columns: ${e.message}"
)
}
}
}) {
Text("Retry")
}
}
}
// Show column headers as clickable cards in a grid
is ColumnsUiState.Success -> {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(state.headers) { header ->
Card(
modifier = Modifier
.fillMaxWidth()
.clickable {
// Show a Toast with the column name on tap
Toast.makeText(
context,
header,
Toast.LENGTH_SHORT
).show()
}
) {
Text(
text = header,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
}
}
What does this code do?
The ColumnsUiState sealed class defines four possible states: Loading, Success, Error, and Empty. A when expression renders the correct UI for whichever state is active.
The LaunchedEffect triggers when the screen appears and calls getColumnHeaders() from your repository. Based on the result, it sets the state to Success (with data), Empty (no headers), or Error (exception caught).
In the Success state, LazyVerticalGrid with GridCells.Fixed(2) renders two columns of cards. Tapping any card triggers a Toast displaying the column header text.
- Save ColumnsScreen.kt.
- Build the project to verify there are no compilation errors.
Build failing on ColumnsScreen?
Make sure your imports include androidx.compose.foundation.lazy.grid.GridCells and androidx.compose.foundation.lazy.grid.items. The items function is an extension on LazyGridScope and will not resolve without the explicit import.
Also confirm that SheetsRepository is accessible from this file. If it is in a different package, add the appropriate import statement.
help me fix ColumnsScreen compilation errors
✔️ Awesome, I've got everything!
Great. Make sure ColumnsScreen.kt is saved before continuing.
ⓧ I'd like to double check the full code
Here is the complete ColumnsScreen.kt file:
package com.example.sheetnavigator
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
sealed class ColumnsUiState {
object Loading : ColumnsUiState()
data class Success(val headers: List<String>) : ColumnsUiState()
data class Error(val message: String) : ColumnsUiState()
object Empty : ColumnsUiState()
}
@Composable
fun ColumnsScreen(
spreadsheetId: String,
tabName: String,
sheetsRepository: SheetsRepository
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var uiState by remember { mutableStateOf<ColumnsUiState>(ColumnsUiState.Loading) }
LaunchedEffect(spreadsheetId, tabName) {
uiState = ColumnsUiState.Loading
try {
val headers = sheetsRepository.getColumnHeaders(spreadsheetId, tabName)
uiState = if (headers.isEmpty()) {
ColumnsUiState.Empty
} else {
ColumnsUiState.Success(headers)
}
} catch (e: Exception) {
uiState = ColumnsUiState.Error("Failed to load columns: ${e.message}")
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = "Columns in $tabName",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp)
)
when (val state = uiState) {
is ColumnsUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is ColumnsUiState.Empty -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "This tab has no column headers.",
style = MaterialTheme.typography.bodyLarge
)
}
}
is ColumnsUiState.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = state.message,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(bottom = 8.dp)
)
Button(onClick = {
uiState = ColumnsUiState.Loading
coroutineScope.launch {
try {
val headers = sheetsRepository.getColumnHeaders(
spreadsheetId, tabName
)
uiState = if (headers.isEmpty()) {
ColumnsUiState.Empty
} else {
ColumnsUiState.Success(headers)
}
} catch (e: Exception) {
uiState = ColumnsUiState.Error(
"Failed to load columns: ${e.message}"
)
}
}
}) {
Text("Retry")
}
}
}
is ColumnsUiState.Success -> {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(state.headers) { header ->
Card(
modifier = Modifier
.fillMaxWidth()
.clickable {
Toast.makeText(
context,
header,
Toast.LENGTH_SHORT
).show()
}
) {
Text(
text = header,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
}
}
Verify the edge cases
Your ColumnsScreen handles three edge cases through the sealed class states. Now you will verify each one works correctly on device.
- Run the app on your emulator or device.
- Navigate through the sign-in flow, then tap any Google Sheet in your Drive tree.
- Tap a tab that has column headers in the first row.
You should see the column header values displayed as cards in a two-column grid. Tap any card and a Toast appears at the bottom of the screen showing the column name.
How to test the empty and error states
To verify the empty state, create a new blank tab in one of your Google Sheets (no data at all in row 1). Navigate to that tab in the app and you should see the message "This tab has no column headers." centered on screen.
To test the error state, toggle airplane mode on your device before tapping a tab. The screen displays the error message with a Retry button. Turning off airplane mode and tapping Retry fetches the data successfully.
- Confirm the loading spinner appears briefly before the cards render.
- Confirm the empty message appears for tabs with no first-row data.
- Confirm the error state with Retry button appears when the network is unavailable.
Cards not appearing after tapping a tab?
Check that your NavHost passes the correct spreadsheetId and tabName arguments to ColumnsScreen. A mismatch in navigation argument names is the most common cause of a blank screen.
Also verify that the SheetsRepository instance passed to ColumnsScreen uses the same authenticated OkHttp client that injects the Bearer token. Without the token, the API returns a 401 error.
help me debug why my ColumnsScreen shows an error or blank screen
Your multi-screen navigation flow is now complete. You can drill down from your Drive folder tree, into a spreadsheet's tabs, and all the way to individual column headers. Next up, see if you can take this further with a search feature in the Secret Mission.
Secret mission
Add Search and Filter to Your Drive Tree
Your Drive tree works great for browsing, but what happens when you have dozens of folders and sheets? In this secret mission, you'll add a real-time search bar that filters the entire tree as you type, showing only matching folders and sheets with their full path breadcrumbs.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely in testing mode on Google Cloud, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Google Cloud project with Drive API and Sheets API enabled.
- OAuth consent screen in testing mode (limited to test users you add manually).
- OAuth 2.0 Client IDs (Android and Web application).
- Local Android Studio project files on your computer.
Keep everything running
No action needed. Choose this if you plan to continue to Project 2, which builds on this app to generate OMR bubble sheets.
- Your Google Cloud project stays active in testing mode with no charges.
- Your Android Studio project remains on your local machine, ready to extend.
- The OAuth consent screen stays in testing mode, accessible only to test users you added.
Pause - I'll come back to this later
There is nothing to pause. Testing mode incurs no charges, and your local project files use only disk space. Everything stays as-is until you return.
- Your Google Cloud project remains active at no cost while in testing mode.
- Your local Android Studio project stays on disk until you decide to delete it.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to clean up everything.
Delete the Google Cloud project
- Open the Google Cloud console at console.cloud.google.com.
- Navigate to the Manage resources page.
- In the project list, select the project you created for this app.
- Click Delete.
- In the dialog, type the project ID, then click Shut down to confirm deletion.
What does deleting the project remove?
Deleting the Google Cloud project removes the Drive API and Sheets API configuration, the OAuth consent screen, and both OAuth Client IDs in one action. You do not need to delete these individually.
Delete local project files
Remove the Android Studio project folder from your computer.
macOS
- Open Terminal and run:
cd ~/Desktop
rm -rf <your-project-folder-name>
- Replace <your-project-folder-name> with the actual name of your Android Studio project folder.
Windows
- Open Command Prompt and run:
cd %USERPROFILE%\Desktop
rmdir /s /q <your-project-folder-name>
- Replace <your-project-folder-name> with the actual name of your Android Studio project folder.
That's a Wrap!
That's a Wrap!
Nice work! You've just built a fully functional Android app that authenticates with Google, browses your Drive as a filtered tree, and drills into any spreadsheet to reveal its tabs and column headers.
You've learned how to:
- Built a Jetpack Compose Android app with Google Sign-In via Credential Manager and AuthorizationClient to obtain an access token for API calls.
- Connected to the Google Drive API v3 to fetch folders and Google Sheets, built a recursive tree data structure on the client side, and pruned empty folders so only relevant paths are visible.
- Used the Google Sheets API v4 to load tab metadata and first-row column headers, displaying them as interactive Material 3 cards across a multi-screen navigation flow.
- Secret Mission: Added a real-time search bar with recursive filtering logic to find sheets by name across the entire Drive tree.
Ready to quiz yourself?