Sync EvalBee Scores to Google Sheets
Build an Android app that parses OMR exports and writes scores to Google Sheets.
Introduction
30 Second Summary
If you use EvalBee to scan student answer sheets, you know the pain of manually typing scores into your class record. Looking up each roll number and entering their score one by one takes forever and introduces mistakes.
In this project, you will build a native Android app that reads EvalBee's exported .xls files and writes scores directly into your Google Sheets class record. The app handles everything automatically by matching roll numbers to rows and syncing scores in seconds.
What You'll Build
A Kotlin Android app that bridges EvalBee OMR scanner exports with your Google Sheets class record using Apache POI for Excel parsing and the Google Sheets API for cloud writes.
By the end of this project, you'll have:
- An Excel parser that reads EvalBee .xls exports and extracts student roll numbers and scores using Apache POI.
- A Google OAuth sign-in flow with a Google Drive browser that lists your spreadsheets and lets you pick any class record.
- Score sync logic that matches roll numbers to rows and writes scores into the correct cells using the Sheets API batchUpdate method.
- Secret Mission: Add a column auto-detection feature that reads sheet headers and lets you select which activity column to write scores to from a dropdown.
Are there any prerequisites?
This project assumes intermediate Kotlin and Android knowledge, including familiarity with Activities and Gradle dependencies. You should also have a physical Android device for testing and a Google account that owns the class record spreadsheets you want to sync to.
Set Up Google Cloud and Android Project
To sync scores between EvalBee and Google Sheets, your app needs permission to access Google's APIs. That requires a Google Cloud project with the right APIs enabled and credentials configured for your Android app.
In this step, you will create your Google Cloud project, enable the Sheets and Drive APIs, set up OAuth credentials, and scaffold your Android Studio project with all the dependencies it needs.
In this step, get ready to:
- Create a Google Cloud project with Sheets API and Drive API enabled.
- Configure OAuth credentials for your Android app.
- Set up an Android Studio project with all required dependencies.
Create a Google Cloud project and enable APIs
Google's APIs require a project in the Google Cloud Console to manage access and credentials. You will enable both the Google Sheets API (to read and write spreadsheet data) and the Google Drive API (to list your spreadsheets).
- Open your browser and go to console.cloud.google.com.
- Sign in with the same Google account that owns your class record spreadsheets.
- Click **Select a project** at the top of the page, then click **New Project**.
- Enter EvalBee Sync as the project name.
- Click Create.
- Once the project is created, make sure it is selected in the project dropdown at the top.
Now enable the two APIs your app needs.
- In the left sidebar, navigate to APIs and Services, then click Library.
- Search for Google Sheets API and click on it.
- Click Enable.
- Return to the Library page.
- Search for Google Drive API and click on it.
- Click Enable.
Why two APIs?
The Google Sheets API lets your app read and write cell values in a spreadsheet. The Google Drive API lets your app list the spreadsheets in a user's Drive so they can pick one without hardcoding a URL.
Next, configure the OAuth consent screen so Google knows what your app will request from users.
- In the left sidebar, go to APIs and Services, then click OAuth consent screen.
- Select External as the user type, then click Create.
- Enter an app name (e.g. EvalBee Sync) and your email address for the required fields.
- Under Scopes, click Add or Remove Scopes.
- Add the scope https://www.googleapis.com/auth/spreadsheets.
- Add the scope https://www.googleapis.com/auth/drive.readonly.
- Click Update, then Save and Continue through the remaining screens.
What are scopes?
Scopes define what your app can do with a user's data. The spreadsheets scope grants read/write access to spreadsheet cells. The drive.readonly scope grants read-only access to list files, following the principle of least privilege.
Now create the OAuth client ID that ties these permissions to your Android app.
- In the left sidebar, go to APIs and Services, then click Credentials.
- Click Create Credentials, then select OAuth client ID.
- For Application type, select Android.
- Enter com.example.evalbeesync as the package name.
- For the SHA-1 certificate fingerprint, you need to get it from your debug keystore. Open a terminal and run the command for your operating system (see below).
macOS
- Get your debug keystore SHA-1 fingerprint by running this command:
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
Look for the line labelled SHA1: in the output and copy the full fingerprint value.
Windows
- Get your debug keystore SHA-1 fingerprint by running this command:
keytool -list -v -keystore "%USERPROFILE%\.android\debug.keystore" -alias androiddebugkey -storepass android -keypass android
Look for the line labelled SHA1: in the output and copy the full fingerprint value.
- Paste the SHA-1 fingerprint into the SHA-1 certificate fingerprint field in Google Cloud Console.
- Click Create.
What is the SHA-1 fingerprint for?
The SHA-1 fingerprint uniquely identifies your app's signing certificate. Google uses it to verify that only your app (signed with your debug key) can use these OAuth credentials.
keytool command not found?
The keytool utility comes with the Java Development Kit (JDK). If it is not on your PATH, you can find it inside your Android Studio installation's embedded JDK folder, or install a standalone JDK.
help me find keytool on my machine
Create the Android Studio project with dependencies
With your cloud project ready, you can create the Android app that will use those APIs. You will configure Apache POI for Excel parsing, Google API client libraries for Sheets and Drive, and coroutines for background networking.
- Open Android Studio.
- Click File, then New, then New Project.
- Select Empty Activity and click Next.
- Set the Name to EvalBeeSync.
- Set the Package name to com.example.evalbeesync (this must match what you entered in Google Cloud).
- Set the Language to Kotlin.
- Set Minimum SDK to API 26.
- Click Finish.
Why API 26 minimum?
Apache POI requires API 26 or higher on Android to avoid conflicts with javax.xml packages. This covers the vast majority of active Android devices.
Now add the project dependencies. These libraries give your app Excel parsing, Google authentication, and API access.
- In the Project pane on the left, expand Gradle Scripts.
- Open the file named build.gradle.kts (Module :app).
- Replace the entire file contents with the following code:
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.evalbeesync"
compileSdk = 34
defaultConfig {
applicationId = "com.example.evalbeesync"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "/META-INF/DEPENDENCIES"
excludes += "/META-INF/LICENSE"
excludes += "/META-INF/LICENSE.txt"
excludes += "/META-INF/NOTICE"
excludes += "/META-INF/NOTICE.txt"
}
}
}
dependencies {
// Apache POI for reading .xls files
implementation("org.apache.poi:poi:5.5.1")
// Google Sign-In for OAuth authentication
implementation("com.google.android.gms:play-services-auth:21.6.0")
// Google API client for Android
implementation("com.google.api-client:google-api-client-android:2.0.0") {
exclude(group = "org.apache.httpcomponents")
}
// Google Sheets API v4
implementation("com.google.apis:google-api-services-sheets:v4-rev20220927-2.0.0")
// Google Drive API v3
implementation("com.google.apis:google-api-services-drive:v3-rev20220815-2.0.0")
// JSON parser for Google API responses
implementation("com.google.http-client:google-http-client-gson:1.42.3")
// AndroidX core libraries
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.activity:activity-ktx:1.8.2")
// Coroutines for background API calls
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}
What do these dependencies do?
- poi:5.5.1 provides the HSSF classes to read .xls files (EvalBee's export format).
- play-services-auth handles Google Sign-In and OAuth token management.
- google-api-client-android bridges the Google Java API client library with Android's HTTP stack.
- google-api-services-sheets and google-api-services-drive provide typed Java wrappers around the Sheets v4 and Drive v3 REST APIs.
- kotlinx-coroutines-android lets you run network calls off the main thread without blocking the UI.
Now add the INTERNET permission so your app can make network requests to Google's servers.
- In the Project pane, navigate to app > src > main.
- Open AndroidManifest.xml.
- Replace the entire file contents with the following:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="EvalBee Sync"
android:theme="@style/Theme.MaterialComponents.Light.DarkActionBar">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
What does this manifest do?
The INTERNET permission allows the app to make HTTP requests to Google's API servers. The application block sets the app label to "EvalBee Sync" and uses Material Components for the UI theme.
Verify the setup compiles
Before moving on, confirm everything is wired up correctly by building and running the app.
- Click File, then Sync Project with Gradle Files (or click the elephant icon in the toolbar).
- Wait for Gradle to finish syncing. You should see "BUILD SUCCESSFUL" in the Build output panel at the bottom.
- Connect your physical Android device via USB and make sure USB debugging is enabled.
- Click the green Run button (triangle icon) in the toolbar.
- Select your physical device from the device list and click OK.
Your app should launch on your device showing a blank screen with "EvalBee Sync" in the app bar.
Build failed with dependency errors?
If you see errors about duplicate classes or conflicting META-INF files, double-check that the packaging block in your build.gradle.kts matches the code above exactly. The excludes lines prevent Apache POI's bundled license files from conflicting with Google's libraries.
help me fix my EvalBeeSync Gradle build errors
✔️ Awesome, I've got everything!
Great. Make sure you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete build.gradle.kts file:
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.evalbeesync"
compileSdk = 34
defaultConfig {
applicationId = "com.example.evalbeesync"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "/META-INF/DEPENDENCIES"
excludes += "/META-INF/LICENSE"
excludes += "/META-INF/LICENSE.txt"
excludes += "/META-INF/NOTICE"
excludes += "/META-INF/NOTICE.txt"
}
}
}
dependencies {
implementation("org.apache.poi:poi:5.5.1")
implementation("com.google.android.gms:play-services-auth:21.6.0")
implementation("com.google.api-client:google-api-client-android:2.0.0") {
exclude(group = "org.apache.httpcomponents")
}
implementation("com.google.apis:google-api-services-sheets:v4-rev20220927-2.0.0")
implementation("com.google.apis:google-api-services-drive:v3-rev20220815-2.0.0")
implementation("com.google.http-client:google-http-client-gson:1.42.3")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.activity:activity-ktx:1.8.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}
And here is the complete AndroidManifest.xml:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="EvalBee Sync"
android:theme="@style/Theme.MaterialComponents.Light.DarkActionBar">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Your development environment is fully configured. Next up, you will build the file picker and Excel parser to read EvalBee exports.
Parse the EvalBee Excel Export
Your Android project is set up with all the dependencies it needs. Now it is time to build the core feature: reading an EvalBee .xls export and extracting student scores from it.
EvalBee exports scanned OMR results as .xls files containing roll numbers and scores. Your app needs to let the user pick one of these files from their device, parse the spreadsheet data, and display the results on screen.
In this step, get ready to:
- Build a file picker that opens .xls files from device storage.
- Create an Excel parser that extracts roll numbers and scores.
- Display the parsed student data on screen for verification.
Create the app layout
Before writing any Kotlin logic, you need a user interface. The layout defines the buttons and text areas your app displays.
- In Android Studio, expand app > src > main > res > layout in the project sidebar.
- Open activity_main.xml.
- Replace everything in the file with this layout:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="EvalBee Score Sync"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<Button
android:id="@+id/importButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Import EvalBee File" />
<Button
android:id="@+id/connectButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Connect and Select Class Record"
android:layout_marginTop="8dp" />
<TextView
android:id="@+id/selectedSheetText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="No class record selected"
android:textSize="14sp"
android:textColor="#666666"
android:layout_marginTop="4dp" />
<Button
android:id="@+id/syncButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Sync Scores"
android:layout_marginTop="8dp" />
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Ready"
android:textSize="16sp"
android:layout_marginTop="16dp" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="8dp">
<TextView
android:id="@+id/resultText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:fontFamily="monospace" />
</ScrollView>
</LinearLayout>
What does this layout do?
The layout creates a vertical stack of three buttons and two text areas. The importButton triggers the file picker. The connectButton and syncButton handle Google Sign-In and score syncing (built in later steps).
The statusText shows progress messages, and resultText inside a ScrollView displays the parsed student data so you can scroll through all results.
- Save activity_main.xml.
Build the Excel parser
EvalBee exports data in the older .xls format (not .xlsx). Apache POI provides the HSSFWorkbook class specifically for reading this format.
First, you need a simple data class to hold each student's parsed result. Create a new Kotlin file for it:
- Right-click on the com.example.evalbeesync package in Android Studio's project sidebar.
- Select New > Kotlin Class/File.
- Name it StudentScore and select Data Class.
- Replace the file contents with this code:
package com.example.evalbeesync
data class StudentScore(
val rollNumber: String,
val score: Double
)
What is a data class?
A Kotlin data class automatically generates toString(), equals(), and copy() methods. It is perfect for holding simple structured data like a student's roll number and score.
- Save StudentScore.kt.
Now create the parser class that reads the .xls file and returns a list of student scores:
- Right-click on the com.example.evalbeesync package again.
- Select New > Kotlin Class/File.
- Name it ExcelParser and select Class.
- Replace the file contents with this code:
package com.example.evalbeesync
import org.apache.poi.hssf.usermodel.HSSFWorkbook
import org.apache.poi.ss.usermodel.CellType
import java.io.InputStream
class ExcelParser {
fun parse(inputStream: InputStream): List<StudentScore> {
val students = mutableListOf<StudentScore>()
val workbook = HSSFWorkbook(inputStream)
val sheet = workbook.getSheetAt(0)
// Skip the header row (index 0) and iterate through data rows
for (i in 1..sheet.lastRowNum) {
val row = sheet.getRow(i) ?: continue
// Read roll number from first column
val rollCell = row.getCell(0) ?: continue
val rollNumber = when (rollCell.cellType) {
CellType.NUMERIC -> rollCell.numericCellValue.toInt().toString().padStart(2, '0')
CellType.STRING -> rollCell.stringCellValue.trim().padStart(2, '0')
else -> continue
}
// Read score from second column
val scoreCell = row.getCell(1) ?: continue
val score = when (scoreCell.cellType) {
CellType.NUMERIC -> scoreCell.numericCellValue
CellType.STRING -> scoreCell.stringCellValue.toDoubleOrNull() ?: continue
else -> continue
}
students.add(StudentScore(rollNumber, score))
}
workbook.close()
return students
}
}
What does this parser do?
The parser opens the .xls file using HSSFWorkbook (the Apache POI class for the older Excel binary format). It reads the first sheet and skips row 0 (the header).
For each data row, it reads the roll number from column 0 and the score from column 1. The padStart(2, '0') call ensures single-digit roll numbers get a leading zero (e.g., 5 becomes 05). This matches EvalBee's 2-digit bubble format.
- Save ExcelParser.kt.
Seeing red underlines on the imports?
If Android Studio shows unresolved reference errors on org.apache.poi.hssf.usermodel.HSSFWorkbook, make sure you synced Gradle after adding the poi dependency in Step 1. Click File > Sync Project with Gradle Files to resolve it.
Help me fix unresolved Apache POI imports in my Android project
Wire up the file picker and display results
Now you need an Activity that launches a file picker, feeds the selected file to your parser, and shows the results. The ActivityResultContracts.OpenDocument contract handles the file selection system dialog.
- Open MainActivity.kt in the com.example.evalbeesync package.
- Replace the entire file contents with the following code:
package com.example.evalbeesync
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.Scope
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.services.drive.DriveScopes
import com.google.api.services.sheets.v4.SheetsScopes
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity() {
private var selectedSpreadsheetId: String = ""
private var selectedSpreadsheetName: String = ""
private val sheetName = "Sheet1"
private val targetColumn = "D"
private var parsedScores: List<StudentScore> = emptyList()
private var googleAccount: GoogleSignInAccount? = null
private lateinit var statusText: TextView
private lateinit var resultText: TextView
private lateinit var selectedSheetText: TextView
// File picker launcher - opens the system file browser filtered to .xls files
private val filePickerLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri != null) {
val inputStream = contentResolver.openInputStream(uri)
if (inputStream != null) {
val parser = ExcelParser()
parsedScores = parser.parse(inputStream)
inputStream.close()
statusText.text = "Parsed ${parsedScores.size} student scores"
resultText.text = parsedScores.joinToString("\n") {
"Roll: ${it.rollNumber} | Score: ${it.score}"
}
}
}
}
// Google Sign-In launcher - handles the OAuth flow result
private val signInLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
task.addOnSuccessListener { account ->
googleAccount = account
statusText.text = "Signed in as ${account.email}"
Toast.makeText(this, "Connected to Google", Toast.LENGTH_SHORT).show()
browseSpreadsheets()
}.addOnFailureListener { e ->
statusText.text = "Sign-in failed: ${e.message}"
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
statusText = findViewById(R.id.statusText)
resultText = findViewById(R.id.resultText)
selectedSheetText = findViewById(R.id.selectedSheetText)
val importButton: Button = findViewById(R.id.importButton)
val connectButton: Button = findViewById(R.id.connectButton)
val syncButton: Button = findViewById(R.id.syncButton)
// Launch file picker filtered to Excel .xls files
importButton.setOnClickListener {
filePickerLauncher.launch(arrayOf("application/vnd.ms-excel"))
}
connectButton.setOnClickListener {
requestSignIn()
}
syncButton.setOnClickListener {
syncScores()
}
}
private fun requestSignIn() {
val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestEmail()
.requestScopes(
Scope(SheetsScopes.SPREADSHEETS),
Scope(DriveScopes.DRIVE_READONLY)
)
.build()
val client = GoogleSignIn.getClient(this, signInOptions)
signInLauncher.launch(client.signInIntent)
}
private fun browseSpreadsheets() {
val account = googleAccount ?: return
statusText.text = "Loading your spreadsheets..."
val credential = GoogleAccountCredential.usingOAuth2(
this, listOf(SheetsScopes.SPREADSHEETS, DriveScopes.DRIVE_READONLY)
)
credential.selectedAccount = account.account
CoroutineScope(Dispatchers.IO).launch {
try {
val driveHelper = DriveHelper(credential)
val spreadsheets = driveHelper.listSpreadsheets()
withContext(Dispatchers.Main) {
if (spreadsheets.isEmpty()) {
statusText.text = "No spreadsheets found in your Drive"
return@withContext
}
val names = spreadsheets.map { it.name }.toTypedArray()
AlertDialog.Builder(this@MainActivity)
.setTitle("Select Class Record")
.setItems(names) { _, which ->
val selected = spreadsheets[which]
selectedSpreadsheetId = selected.id
selectedSpreadsheetName = selected.name
selectedSheetText.text = "Selected: ${selected.name}"
statusText.text = "Ready to sync"
}
.show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
statusText.text = "Failed to load spreadsheets: ${e.message}"
}
}
}
}
private fun syncScores() {
val account = googleAccount
if (account == null) {
Toast.makeText(this, "Please sign in first", Toast.LENGTH_SHORT).show()
return
}
if (parsedScores.isEmpty()) {
Toast.makeText(this, "Please import an EvalBee file first", Toast.LENGTH_SHORT).show()
return
}
if (selectedSpreadsheetId.isEmpty()) {
Toast.makeText(this, "Please select a class record first", Toast.LENGTH_SHORT).show()
return
}
statusText.text = "Syncing scores..."
val credential = GoogleAccountCredential.usingOAuth2(
this, listOf(SheetsScopes.SPREADSHEETS, DriveScopes.DRIVE_READONLY)
)
credential.selectedAccount = account.account
CoroutineScope(Dispatchers.IO).launch {
try {
val helper = SheetsHelper(credential)
val rollNumbers = helper.readColumn(selectedSpreadsheetId, "'$sheetName'!A:A")
val rollNumberToRow = mutableMapOf<String, Int>()
for (i in rollNumbers.indices) {
val value = rollNumbers[i].firstOrNull()?.toString()?.trim()?.padStart(2, '0')
if (value != null) {
rollNumberToRow[value] = i + 1
}
}
val syncedCount = helper.syncScores(
selectedSpreadsheetId, sheetName, parsedScores, rollNumberToRow, targetColumn
)
val notFound = parsedScores.size - syncedCount
withContext(Dispatchers.Main) {
statusText.text = "Sync complete!"
resultText.text = "$syncedCount scores synced to '$selectedSpreadsheetName'" +
if (notFound > 0) "\n$notFound roll numbers not found in class record" else ""
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
statusText.text = "Sync failed: ${e.message}"
}
}
}
}
}
What does this code do?
The filePickerLauncher uses ActivityResultContracts.OpenDocument() to open Android's system file browser. The MIME type filter application/vnd.ms-excel ensures only .xls files appear. When a file is selected, contentResolver.openInputStream(uri) opens it for reading.
The file picker result callback creates an ExcelParser, passes the stream, and displays each student's roll number and score in the resultText view. The browseSpreadsheets() and syncScores() methods reference helper classes we will create in the next steps.
- Save MainActivity.kt.
Why are there red errors in my code?
Android Studio will show unresolved reference errors for DriveHelper and SheetsHelper. This is expected. You will create these classes in the next two steps. The file picker and parsing logic are complete and will work once those helpers exist.
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files: activity_main.xml, StudentScore.kt, ExcelParser.kt, and MainActivity.kt.
ⓧ I'd like to double check the full code
Here is the full code for each file at this point:
package com.example.evalbeesync
data class StudentScore(
val rollNumber: String,
val score: Double
)
package com.example.evalbeesync
import org.apache.poi.hssf.usermodel.HSSFWorkbook
import org.apache.poi.ss.usermodel.CellType
import java.io.InputStream
class ExcelParser {
fun parse(inputStream: InputStream): List<StudentScore> {
val students = mutableListOf<StudentScore>()
val workbook = HSSFWorkbook(inputStream)
val sheet = workbook.getSheetAt(0)
for (i in 1..sheet.lastRowNum) {
val row = sheet.getRow(i) ?: continue
val rollCell = row.getCell(0) ?: continue
val rollNumber = when (rollCell.cellType) {
CellType.NUMERIC -> rollCell.numericCellValue.toInt().toString().padStart(2, '0')
CellType.STRING -> rollCell.stringCellValue.trim().padStart(2, '0')
else -> continue
}
val scoreCell = row.getCell(1) ?: continue
val score = when (scoreCell.cellType) {
CellType.NUMERIC -> scoreCell.numericCellValue
CellType.STRING -> scoreCell.stringCellValue.toDoubleOrNull() ?: continue
else -> continue
}
students.add(StudentScore(rollNumber, score))
}
workbook.close()
return students
}
}
package com.example.evalbeesync
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.Scope
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.services.drive.DriveScopes
import com.google.api.services.sheets.v4.SheetsScopes
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity() {
private var selectedSpreadsheetId: String = ""
private var selectedSpreadsheetName: String = ""
private val sheetName = "Sheet1"
private val targetColumn = "D"
private var parsedScores: List<StudentScore> = emptyList()
private var googleAccount: GoogleSignInAccount? = null
private lateinit var statusText: TextView
private lateinit var resultText: TextView
private lateinit var selectedSheetText: TextView
private val filePickerLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri != null) {
val inputStream = contentResolver.openInputStream(uri)
if (inputStream != null) {
val parser = ExcelParser()
parsedScores = parser.parse(inputStream)
inputStream.close()
statusText.text = "Parsed ${parsedScores.size} student scores"
resultText.text = parsedScores.joinToString("\n") {
"Roll: ${it.rollNumber} | Score: ${it.score}"
}
}
}
}
private val signInLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
task.addOnSuccessListener { account ->
googleAccount = account
statusText.text = "Signed in as ${account.email}"
Toast.makeText(this, "Connected to Google", Toast.LENGTH_SHORT).show()
browseSpreadsheets()
}.addOnFailureListener { e ->
statusText.text = "Sign-in failed: ${e.message}"
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
statusText = findViewById(R.id.statusText)
resultText = findViewById(R.id.resultText)
selectedSheetText = findViewById(R.id.selectedSheetText)
val importButton: Button = findViewById(R.id.importButton)
val connectButton: Button = findViewById(R.id.connectButton)
val syncButton: Button = findViewById(R.id.syncButton)
importButton.setOnClickListener {
filePickerLauncher.launch(arrayOf("application/vnd.ms-excel"))
}
connectButton.setOnClickListener {
requestSignIn()
}
syncButton.setOnClickListener {
syncScores()
}
}
private fun requestSignIn() {
val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestEmail()
.requestScopes(
Scope(SheetsScopes.SPREADSHEETS),
Scope(DriveScopes.DRIVE_READONLY)
)
.build()
val client = GoogleSignIn.getClient(this, signInOptions)
signInLauncher.launch(client.signInIntent)
}
private fun browseSpreadsheets() {
val account = googleAccount ?: return
statusText.text = "Loading your spreadsheets..."
val credential = GoogleAccountCredential.usingOAuth2(
this, listOf(SheetsScopes.SPREADSHEETS, DriveScopes.DRIVE_READONLY)
)
credential.selectedAccount = account.account
CoroutineScope(Dispatchers.IO).launch {
try {
val driveHelper = DriveHelper(credential)
val spreadsheets = driveHelper.listSpreadsheets()
withContext(Dispatchers.Main) {
if (spreadsheets.isEmpty()) {
statusText.text = "No spreadsheets found in your Drive"
return@withContext
}
val names = spreadsheets.map { it.name }.toTypedArray()
AlertDialog.Builder(this@MainActivity)
.setTitle("Select Class Record")
.setItems(names) { _, which ->
val selected = spreadsheets[which]
selectedSpreadsheetId = selected.id
selectedSpreadsheetName = selected.name
selectedSheetText.text = "Selected: ${selected.name}"
statusText.text = "Ready to sync"
}
.show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
statusText.text = "Failed to load spreadsheets: ${e.message}"
}
}
}
}
private fun syncScores() {
val account = googleAccount
if (account == null) {
Toast.makeText(this, "Please sign in first", Toast.LENGTH_SHORT).show()
return
}
if (parsedScores.isEmpty()) {
Toast.makeText(this, "Please import an EvalBee file first", Toast.LENGTH_SHORT).show()
return
}
if (selectedSpreadsheetId.isEmpty()) {
Toast.makeText(this, "Please select a class record first", Toast.LENGTH_SHORT).show()
return
}
statusText.text = "Syncing scores..."
val credential = GoogleAccountCredential.usingOAuth2(
this, listOf(SheetsScopes.SPREADSHEETS, DriveScopes.DRIVE_READONLY)
)
credential.selectedAccount = account.account
CoroutineScope(Dispatchers.IO).launch {
try {
val helper = SheetsHelper(credential)
val rollNumbers = helper.readColumn(selectedSpreadsheetId, "'$sheetName'!A:A")
val rollNumberToRow = mutableMapOf<String, Int>()
for (i in rollNumbers.indices) {
val value = rollNumbers[i].firstOrNull()?.toString()?.trim()?.padStart(2, '0')
if (value != null) {
rollNumberToRow[value] = i + 1
}
}
val syncedCount = helper.syncScores(
selectedSpreadsheetId, sheetName, parsedScores, rollNumberToRow, targetColumn
)
val notFound = parsedScores.size - syncedCount
withContext(Dispatchers.Main) {
statusText.text = "Sync complete!"
resultText.text = "$syncedCount scores synced to '$selectedSpreadsheetName'" +
if (notFound > 0) "\n$notFound roll numbers not found in class record" else ""
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
statusText.text = "Sync failed: ${e.message}"
}
}
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="EvalBee Score Sync"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<Button
android:id="@+id/importButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Import EvalBee File" />
<Button
android:id="@+id/connectButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Connect and Select Class Record"
android:layout_marginTop="8dp" />
<TextView
android:id="@+id/selectedSheetText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="No class record selected"
android:textSize="14sp"
android:textColor="#666666"
android:layout_marginTop="4dp" />
<Button
android:id="@+id/syncButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Sync Scores"
android:layout_marginTop="8dp" />
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Ready"
android:textSize="16sp"
android:layout_marginTop="16dp" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="8dp">
<TextView
android:id="@+id/resultText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:fontFamily="monospace" />
</ScrollView>
</LinearLayout>
Your parsing logic is ready. Once you build the Drive and Sheets helper classes in the next steps, the app will compile and you will be able to test the full import flow on your device. Next up, you will connect to Google Drive so users can browse and select their class record spreadsheet.
Browse Google Drive and Select a Class Record
Your app can now parse EvalBee exports and display student scores. The next challenge is connecting to Google Drive so the user can pick which class record to sync those scores into.
Instead of hardcoding a spreadsheet URL, you will authenticate with Google and let the user browse their own Drive. This makes the app work with any class record, not just one specific sheet.
In this step, get ready to:
- Implement Google Sign-In with Drive and Sheets scopes.
- Create a Drive helper that lists your spreadsheets.
- Let the user select a spreadsheet from a dialog.
Implement Google Sign-In with Drive and Sheets scopes
To access a user's Google Drive and Sheets, your app needs their permission via OAuth 2.0. The Google Sign-In flow presents an account picker and consent screen, then returns a credential you can use for API calls.
- In Android Studio, open MainActivity.kt.
- Find the requestSignIn() function. This is the method that gets called when the user taps the Connect and Select Class Record button.
- Replace the empty or placeholder requestSignIn() function with this implementation:
private fun requestSignIn() {
val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestEmail()
.requestScopes(
Scope(SheetsScopes.SPREADSHEETS),
Scope(DriveScopes.DRIVE_READONLY)
)
.build()
val client = GoogleSignIn.getClient(this, signInOptions)
signInLauncher.launch(client.signInIntent)
}
What does this code do?
GoogleSignInOptions.Builder configures what permissions the app requests from the user. DEFAULT_SIGN_IN gives you basic profile info.
The two requestScopes calls add the Google Sheets API write scope and the Google Drive API read-only scope. These match the scopes you configured on your OAuth consent screen in Step 1.
The signInLauncher is an ActivityResultLauncher that fires the sign-in intent and catches the result. When sign-in succeeds, it stores the GoogleSignInAccount and automatically calls browseSpreadsheets().
Find the signInLauncher property near the top of the class to see how the result is handled.
Why use the legacy Google Sign-In API?
Google has deprecated GoogleSignInClient in favour of Credential Manager for authentication. However, Credential Manager separates authentication from authorization. For apps that need OAuth scopes to call the Sheets and Drive APIs directly, the legacy play-services-auth approach still functions and remains the simplest path for scope-based authorization on Android.
The library is still available and works for this use case. In a production app you would migrate to Credential Manager plus the AuthorizationClient API.
List spreadsheets from Google Drive
Now you need a helper class that uses the Drive REST API to fetch all Google Sheets from the signed-in user's account. This keeps the Drive logic separate from your activity.
- In the Project panel on the left, right-click the com.example.evalbeesync package folder.
- Select New then Kotlin Class/File.
- Name it DriveHelper and press Enter.
- Replace the file contents with this code:
package com.example.evalbeesync
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.gson.GsonFactory
import com.google.api.services.drive.Drive
import com.google.api.services.drive.DriveScopes
data class SpreadsheetInfo(
val id: String,
val name: String
)
class DriveHelper(credential: GoogleAccountCredential) {
// Build the Drive service client using the signed-in credential
private val driveService: Drive = Drive.Builder(
NetHttpTransport(),
GsonFactory.getDefaultInstance(),
credential
)
.setApplicationName("EvalBee Sync")
.build()
// Query Drive for all non-trashed Google Sheets
fun listSpreadsheets(): List<SpreadsheetInfo> {
val result = driveService.files().list()
.setQ("mimeType='application/vnd.google-apps.spreadsheet' and trashed=false")
.setSpaces("drive")
.setFields("files(id, name)")
.setPageSize(50)
.execute()
return result.files?.map { file ->
SpreadsheetInfo(id = file.id, name = file.name)
} ?: emptyList()
}
}
What does this code do?
- SpreadsheetInfo is a simple data class holding the spreadsheet's Drive ID and display name.
- The DriveHelper constructor takes a GoogleAccountCredential and builds a Drive service client using NetHttpTransport and GsonFactory.
- listSpreadsheets() calls the Drive API files.list method, filtering to only return Google Sheets that have not been trashed. It requests only the id and name fields to keep the response lightweight.
- The MIME type filter application/vnd.google-apps.spreadsheet is Google's official type for Sheets documents in Drive.
- Save DriveHelper.kt.
Seeing unresolved import errors?
Make sure your build.gradle.kts includes com.google.apis:google-api-services-drive:v3-rev20220815-2.0.0 and that you have synced Gradle. If imports still show red, click File then Sync Project with Gradle Files.
help me fix Drive import errors in my Android project
Let the user select a spreadsheet
The final piece connects the sign-in to the Drive listing. After the user authenticates, your app calls browseSpreadsheets() to fetch the list and show it in a dialog.
- In MainActivity.kt, find the browseSpreadsheets() function. Confirm it matches this implementation:
private fun browseSpreadsheets() {
val account = googleAccount ?: return
statusText.text = "Loading your spreadsheets..."
// Build credential from the signed-in account
val credential = GoogleAccountCredential.usingOAuth2(
this, listOf(SheetsScopes.SPREADSHEETS, DriveScopes.DRIVE_READONLY)
)
credential.selectedAccount = account.account
// Run the Drive query on a background thread
CoroutineScope(Dispatchers.IO).launch {
try {
val driveHelper = DriveHelper(credential)
val spreadsheets = driveHelper.listSpreadsheets()
withContext(Dispatchers.Main) {
if (spreadsheets.isEmpty()) {
statusText.text = "No spreadsheets found in your Drive"
return@withContext
}
// Show a dialog listing all spreadsheet names
val names = spreadsheets.map { it.name }.toTypedArray()
AlertDialog.Builder(this@MainActivity)
.setTitle("Select Class Record")
.setItems(names) { _, which ->
val selected = spreadsheets[which]
selectedSpreadsheetId = selected.id
selectedSpreadsheetName = selected.name
selectedSheetText.text = "Selected: ${selected.name}"
statusText.text = "Ready to sync"
}
.show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
statusText.text = "Failed to load spreadsheets: ${e.message}"
}
}
}
}
What does this code do?
- GoogleAccountCredential.usingOAuth2 wraps the signed-in Google account with the scopes the API client needs.
- The Drive query runs on Dispatchers.IO because network calls cannot run on the main thread.
- An AlertDialog presents the list of spreadsheet names. When the user taps one, the app stores the spreadsheet's id and name for later use during sync.
- No hardcoded spreadsheet URL is needed. The app dynamically discovers which sheets the user owns.
- Save MainActivity.kt.
- Build and run the app on your physical device.
- Tap Connect and Select Class Record.
What should I see?
A Google account picker appears. After you select your account and grant permissions, a dialog lists all Google Sheets from your Drive. Tapping one updates the screen to show "Selected: [spreadsheet name]".
Sign-in fails or no spreadsheets appear?
- Confirm you are signing in with the same Google account that owns your class record spreadsheets.
- Check that your OAuth client ID in Google Cloud Console uses the correct SHA-1 fingerprint and package name com.example.evalbeesync.
- If you see a "This app is blocked" screen, make sure you added your Google account as a test user on the OAuth consent screen in the Google Cloud Console.
- Verify both the Google Sheets API and Google Drive API are enabled in your Cloud project.
help me fix Google Sign-In or Drive API issues in my Android app
✔️ Awesome, I've got everything!
Great. Double check you have saved both DriveHelper.kt and MainActivity.kt.
ⓧ I'd like to double check the full code
Here is the complete DriveHelper.kt file:
package com.example.evalbeesync
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.gson.GsonFactory
import com.google.api.services.drive.Drive
import com.google.api.services.drive.DriveScopes
data class SpreadsheetInfo(
val id: String,
val name: String
)
class DriveHelper(credential: GoogleAccountCredential) {
private val driveService: Drive = Drive.Builder(
NetHttpTransport(),
GsonFactory.getDefaultInstance(),
credential
)
.setApplicationName("EvalBee Sync")
.build()
fun listSpreadsheets(): List<SpreadsheetInfo> {
val result = driveService.files().list()
.setQ("mimeType='application/vnd.google-apps.spreadsheet' and trashed=false")
.setSpaces("drive")
.setFields("files(id, name)")
.setPageSize(50)
.execute()
return result.files?.map { file ->
SpreadsheetInfo(id = file.id, name = file.name)
} ?: emptyList()
}
}
Here is the complete MainActivity.kt file:
package com.example.evalbeesync
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.Scope
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.services.drive.DriveScopes
import com.google.api.services.sheets.v4.SheetsScopes
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : AppCompatActivity() {
private var selectedSpreadsheetId: String = ""
private var selectedSpreadsheetName: String = ""
private val sheetName = "Sheet1"
private val targetColumn = "D"
private var parsedScores: List<StudentScore> = emptyList()
private var googleAccount: GoogleSignInAccount? = null
private lateinit var statusText: TextView
private lateinit var resultText: TextView
private lateinit var selectedSheetText: TextView
private val filePickerLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri != null) {
val inputStream = contentResolver.openInputStream(uri)
if (inputStream != null) {
val parser = ExcelParser()
parsedScores = parser.parse(inputStream)
inputStream.close()
statusText.text = "Parsed ${parsedScores.size} student scores"
resultText.text = parsedScores.joinToString("\n") {
"Roll: ${it.rollNumber} | Score: ${it.score}"
}
}
}
}
private val signInLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
task.addOnSuccessListener { account ->
googleAccount = account
statusText.text = "Signed in as ${account.email}"
Toast.makeText(this, "Connected to Google", Toast.LENGTH_SHORT).show()
browseSpreadsheets()
}.addOnFailureListener { e ->
statusText.text = "Sign-in failed: ${e.message}"
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
statusText = findViewById(R.id.statusText)
resultText = findViewById(R.id.resultText)
selectedSheetText = findViewById(R.id.selectedSheetText)
val importButton: Button = findViewById(R.id.importButton)
val connectButton: Button = findViewById(R.id.connectButton)
val syncButton: Button = findViewById(R.id.syncButton)
importButton.setOnClickListener {
filePickerLauncher.launch(arrayOf("application/vnd.ms-excel"))
}
connectButton.setOnClickListener {
requestSignIn()
}
syncButton.setOnClickListener {
syncScores()
}
}
private fun requestSignIn() {
val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestEmail()
.requestScopes(
Scope(SheetsScopes.SPREADSHEETS),
Scope(DriveScopes.DRIVE_READONLY)
)
.build()
val client = GoogleSignIn.getClient(this, signInOptions)
signInLauncher.launch(client.signInIntent)
}
private fun browseSpreadsheets() {
val account = googleAccount ?: return
statusText.text = "Loading your spreadsheets..."
val credential = GoogleAccountCredential.usingOAuth2(
this, listOf(SheetsScopes.SPREADSHEETS, DriveScopes.DRIVE_READONLY)
)
credential.selectedAccount = account.account
CoroutineScope(Dispatchers.IO).launch {
try {
val driveHelper = DriveHelper(credential)
val spreadsheets = driveHelper.listSpreadsheets()
withContext(Dispatchers.Main) {
if (spreadsheets.isEmpty()) {
statusText.text = "No spreadsheets found in your Drive"
return@withContext
}
val names = spreadsheets.map { it.name }.toTypedArray()
AlertDialog.Builder(this@MainActivity)
.setTitle("Select Class Record")
.setItems(names) { _, which ->
val selected = spreadsheets[which]
selectedSpreadsheetId = selected.id
selectedSpreadsheetName = selected.name
selectedSheetText.text = "Selected: ${selected.name}"
statusText.text = "Ready to sync"
}
.show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
statusText.text = "Failed to load spreadsheets: ${e.message}"
}
}
}
}
private fun syncScores() {
val account = googleAccount
if (account == null) {
Toast.makeText(this, "Please sign in first", Toast.LENGTH_SHORT).show()
return
}
if (parsedScores.isEmpty()) {
Toast.makeText(this, "Please import an EvalBee file first", Toast.LENGTH_SHORT).show()
return
}
if (selectedSpreadsheetId.isEmpty()) {
Toast.makeText(this, "Please select a class record first", Toast.LENGTH_SHORT).show()
return
}
statusText.text = "Syncing scores..."
val credential = GoogleAccountCredential.usingOAuth2(
this, listOf(SheetsScopes.SPREADSHEETS, DriveScopes.DRIVE_READONLY)
)
credential.selectedAccount = account.account
CoroutineScope(Dispatchers.IO).launch {
try {
val helper = SheetsHelper(credential)
val rollNumbers = helper.readColumn(selectedSpreadsheetId, "'$sheetName'!A:A")
val rollNumberToRow = mutableMapOf<String, Int>()
for (i in rollNumbers.indices) {
val value = rollNumbers[i].firstOrNull()?.toString()?.trim()?.padStart(2, '0')
if (value != null) {
rollNumberToRow[value] = i + 1
}
}
val syncedCount = helper.syncScores(
selectedSpreadsheetId, sheetName, parsedScores, rollNumberToRow, targetColumn
)
val notFound = parsedScores.size - syncedCount
withContext(Dispatchers.Main) {
statusText.text = "Sync complete!"
resultText.text = "$syncedCount scores synced to '$selectedSpreadsheetName'" +
if (notFound > 0) "\n$notFound roll numbers not found in class record" else ""
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
statusText.text = "Sync failed: ${e.message}"
}
}
}
}
}
Your app now authenticates with Google and browses Drive dynamically. Next up, you will write the sync logic that matches roll numbers and pushes scores into the selected class record.
Sync Scores to Your Class Record
Your app can parse EvalBee scores and browse your Google Drive to select a class record. Now it is time to connect the final dots: reading your spreadsheet's structure to find each student's row, then writing their score into the correct cell.
The Google Sheets API provides methods for both reading and writing cell values. You will create a helper class that reads the roll number column to build a lookup map, then uses a batch write to update all scores in a single API call.
In this step, get ready to:
- Create a SheetsHelper class that reads and writes to Google Sheets.
- Map roll numbers from your class record to their row positions.
- Sync all parsed scores to the correct cells using batchUpdate.
Create the SheetsHelper class with a read method
Before you can write scores, you need to read your class record's roll number column to know which row each student occupies. A dedicated helper class keeps the Sheets API logic separate from your activity.
- In Android Studio, create a new Kotlin file at app/src/main/java/com/example/evalbeesync/SheetsHelper.kt by right-clicking the evalbeesync package folder.
- Select New then Kotlin Class/File.
- Name it SheetsHelper and select File as the type.
- Paste the following code into the new file:
package com.example.evalbeesync
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.gson.GsonFactory
import com.google.api.services.sheets.v4.Sheets
import com.google.api.services.sheets.v4.model.BatchUpdateValuesRequest
import com.google.api.services.sheets.v4.model.ValueRange
class SheetsHelper(credential: GoogleAccountCredential) {
// Build the Sheets API service client using the signed-in credential
private val sheetsService: Sheets = Sheets.Builder(
NetHttpTransport(),
GsonFactory.getDefaultInstance(),
credential
)
.setApplicationName("EvalBee Sync")
.build()
// Read a column or range and return raw cell values
fun readColumn(spreadsheetId: String, range: String): List<List<Any>> {
val response = sheetsService.spreadsheets().values()
.get(spreadsheetId, range)
.execute()
return response.getValues() ?: emptyList()
}
}
What does this code do?
The SheetsHelper class wraps the Google Sheets API client, similar to how DriveHelper wraps the Drive client.
- The constructor takes a GoogleAccountCredential and builds a Sheets service instance.
- readColumn() calls spreadsheets.values.get to fetch values from any range you specify (like the entire A column).
- It returns a list of rows, where each row is itself a list of cell values.
Do not save yet. You will add the write method next.
Add the batch write method
Writing scores one cell at a time would mean dozens of separate API calls. The spreadsheets.values.batchUpdate method lets you write all scores in a single request by packing multiple ValueRange objects together.
- In SheetsHelper.kt, add the following syncScores method below the readColumn function, still inside the class body:
fun syncScores(
spreadsheetId: String,
sheetName: String,
scores: List<StudentScore>,
rollNumberToRow: Map<String, Int>,
targetColumn: String
): Int {
val data = mutableListOf<ValueRange>()
var syncedCount = 0
// Build a ValueRange for each student whose roll number exists in the class record
for (student in scores) {
val rowIndex = rollNumberToRow[student.rollNumber] ?: continue
val cellRange = "'$sheetName'!$targetColumn$rowIndex"
val valueRange = ValueRange()
.setRange(cellRange)
.setValues(listOf(listOf(student.score)))
data.add(valueRange)
syncedCount++
}
// Send all writes in one batch request
if (data.isNotEmpty()) {
val batchRequest = BatchUpdateValuesRequest()
.setValueInputOption("RAW")
.setData(data)
sheetsService.spreadsheets().values()
.batchUpdate(spreadsheetId, batchRequest)
.execute()
}
return syncedCount
}
What does this code do?
The syncScores method takes parsed scores, a roll-number-to-row map, and a target column letter. It builds one ValueRange per student pointing at the exact cell (e.g. 'Sheet1'!D7 for roll number "05" in row 7).
The BatchUpdateValuesRequest bundles all those ValueRange objects and sends them in a single API call. Setting valueInputOption to RAW means the numeric scores are inserted as-is, without any formula parsing.
Why RAW instead of USER_ENTERED?
RAW inserts values exactly as provided. Since your scores are plain numbers, there is no need for Sheets to interpret them as formulas or dates. USER_ENTERED would parse values as if typed into the Sheets UI, which could misinterpret certain inputs.
- Save SheetsHelper.kt.
Seeing unresolved reference errors?
Make sure the import statements at the top of the file include BatchUpdateValuesRequest and ValueRange from com.google.api.services.sheets.v4.model.
Also confirm your build.gradle.kts includes the google-api-services-sheets dependency and that Gradle has synced.
help me fix unresolved reference errors in SheetsHelper.kt
✔️ Awesome, I've got everything!
Great. Double check you have saved SheetsHelper.kt before moving on.
ⓧ I'd like to double check the full code
Here is the complete SheetsHelper.kt file:
package com.example.evalbeesync
import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.gson.GsonFactory
import com.google.api.services.sheets.v4.Sheets
import com.google.api.services.sheets.v4.model.BatchUpdateValuesRequest
import com.google.api.services.sheets.v4.model.ValueRange
class SheetsHelper(credential: GoogleAccountCredential) {
private val sheetsService: Sheets = Sheets.Builder(
NetHttpTransport(),
GsonFactory.getDefaultInstance(),
credential
)
.setApplicationName("EvalBee Sync")
.build()
fun readColumn(spreadsheetId: String, range: String): List<List<Any>> {
val response = sheetsService.spreadsheets().values()
.get(spreadsheetId, range)
.execute()
return response.getValues() ?: emptyList()
}
fun syncScores(
spreadsheetId: String,
sheetName: String,
scores: List<StudentScore>,
rollNumberToRow: Map<String, Int>,
targetColumn: String
): Int {
val data = mutableListOf<ValueRange>()
var syncedCount = 0
for (student in scores) {
val rowIndex = rollNumberToRow[student.rollNumber] ?: continue
val cellRange = "'$sheetName'!$targetColumn$rowIndex"
val valueRange = ValueRange()
.setRange(cellRange)
.setValues(listOf(listOf(student.score)))
data.add(valueRange)
syncedCount++
}
if (data.isNotEmpty()) {
val batchRequest = BatchUpdateValuesRequest()
.setValueInputOption("RAW")
.setData(data)
sheetsService.spreadsheets().values()
.batchUpdate(spreadsheetId, batchRequest)
.execute()
}
return syncedCount
}
}
Test the full sync flow
With SheetsHelper in place, your app's syncScores() method in MainActivity can now compile and run the full pipeline.
How does the sync work end to end?
When you tap the Sync Scores button, the syncScores() method in MainActivity does three things:
- Reads column A of your selected spreadsheet to build a map of roll number to row index. Each roll number is padded to 2 digits (e.g. "5" becomes "05") so it matches the EvalBee format.
- Passes the parsed scores and the map to SheetsHelper.syncScores(), which writes each score to column D in the matching row.
- Displays a summary showing how many scores were synced and how many roll numbers were not found in the class record.
- Build and run the app on your physical device.
- Tap Import EvalBee File and select an EvalBee .xls export from your device storage.
- Confirm you see parsed roll numbers and scores on screen.
- Tap Connect and Select Class Record.
- Sign in with the Google account that owns your class record.
- Select your class record from the list that appears.
- Tap Sync Scores.
You should see a status message like "Sync complete!" and a result showing the number of scores synced.
- Open your Google Sheets class record in a browser or the Sheets app.
- Verify that scores now appear in column D next to the correct student roll numbers.
What about roll numbers that were not found?
If an EvalBee export contains a roll number that does not exist in your class record's column A, the app skips it. The summary at the end tells you how many were not found so you can investigate.
Sync failed or zero scores synced?
Check that your class record's first sheet is named Sheet1. The app uses this name by default. If your sheet has a different name, update the sheetName variable in MainActivity to match.
Verify that column A in your class record contains roll numbers in the same format EvalBee exports (2-digit, zero-padded). For example, roll number 5 should appear as "05" or as the number 5 (the app pads it automatically).
Also confirm you are writing to the correct column. The app currently hardcodes column D. If your activity scores belong in a different column, update targetColumn in MainActivity.
help me debug why my scores are not syncing to Google Sheets
Your EvalBee Score Sync app is fully functional. You have eliminated manual data entry by automating the entire flow from OMR scan export to class record update. Next up, you will tackle the Secret Mission and make the target column dynamic instead of hardcoded.
Secret mission
Auto-Detect Activity Columns
Your app currently hardcodes column D as the target for scores. What happens when you need to sync Quiz 2 results to column E, or Midterm scores to column F? In this secret mission, you'll make the app read your class record's headers and present a dropdown of available activity columns.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your device and uses free Google Cloud APIs. There are no ongoing costs. Decide whether to keep your resources available, pause them, or delete them entirely.
Resources you used:
- Google Cloud project ("EvalBee Sync") with OAuth credentials and enabled APIs.
- EvalBee Sync app installed on your physical device.
Keep everything running
No action needed. Choose this if you plan to keep syncing EvalBee scores to your class records.
- Your Google Cloud project stays active at no cost. Standard Sheets and Drive API quotas are more than sufficient for classroom use.
- The app remains on your device ready to use whenever you have new EvalBee exports.
Pause - I'll come back to this later
The app only makes API calls when you actively use it. There are no background processes or running servers to shut down.
- Leave the Google Cloud project as-is. It incurs no charges while idle.
- Leave the app installed on your device. It uses minimal storage and no battery when not in use.
- When you return, open the app and it will work exactly as before.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to clean up completely.
- Shut down your Google Cloud project:
- Go to console.cloud.google.com.
- In the Google Cloud console, go to the IAM & Admin > Settings page.
- Click Shut down.
- Enter your project ID and click Shut down anyway to confirm.
- Uninstall the app from your device:
- Long-press the EvalBee Sync app icon on your home screen and tap Uninstall.
- Delete the Android Studio project folder from your computer if you no longer need the source code.
Nice Work!
Nice Work!
Nice work! You just built a native Android app that eliminates manual data entry between EvalBee's OMR scanner and your Google Sheets class record.
You've learned how to:
- Parse .xls files on Android using Apache POI to extract structured data (roll numbers and scores) from EvalBee exports.
- Authenticate with Google OAuth 2.0 and use the Google Drive API to browse and select spreadsheets directly from your Drive.
- Write scores into specific cells of a Google Sheets class record using the Sheets API v4 batchUpdate method, matched by roll number.
- Secret Mission: Added dynamic column detection so the app reads your class record headers and lets you choose which activity column to write scores to.
Ready to quiz yourself?