Project 21: Job Portal App with Search, Filters and Resume Upload

Android Development with Kotlin: 21 Real Projects · More Real-World Projects · 21 min read · Updated

Filter, choose, apply

  1. The user searches and filters the job list. The shown list is calculated from the filter.
  2. Opening a job shows its details and a resume picker.
  3. Before upload, the app checks the file type and size.
  4. A multipart request sends the note and file. The server validates again and stores the application.

A job portal is a good last project because it joins list screens, filtering, forms, file handling and backend calls. It is also close to this platform's own purpose. The app has two main screens: a searchable, filterable list of jobs, and a detail screen where the user attaches a resume and applies.

The app needs a backend that you provide: GET /jobs returns a list and POST /jobs/{id}/apply accepts a multipart request with a cover note and a file. Authentication is left out here to keep the lesson focused. A real portal would use the sign-in pattern from projects 13 and 18 and attach the token to every request.

Concepts in this project

  • Filtering in plain Kotlin: List<Job>.filtered(JobFilter) combines a text search, a remote-only switch and a job type. It is a pure function with unit tests.
  • Derived list: JobsState.shown is computed from the full list and the filter, so the list and filters can never disagree.
  • Filter chips: FilterChip for remote and job types, toggled on and off.
  • Storage Access Framework: ActivityResultContracts.OpenDocument lets the user pick a file without any storage permission.
  • File checks before upload: resumeProblem rejects the wrong type, an empty file and a file over 5 MB. The server must check again.
  • Multipart upload: a text part for the cover note and a file part for the resume, sent with Retrofit.

Create the project

  1. New Empty Activity project Jobs, package com.example.jobs.
  2. Add Retrofit and serialization from the Project 21 block and the INTERNET permission.
  3. Prepare a backend (or a mock server) that returns the JSON for Job and accepts the upload.
  4. Create the files below.

`Gradle dependencies for projects 11 to 21 (use the block for this project)`

// ---- Project 11 Calculator: no extra libraries. Test dependency:
testImplementation("junit:junit:4.13.2")

// ---- Project 12 E-commerce
plugins { id("com.google.devtools.ksp"); kotlin("plugin.serialization") }
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")

// ---- Project 13 Chat (Firebase)
// project-level plugins: id("com.google.gms.google-services")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-auth")
implementation("com.google.firebase:firebase-firestore")
implementation("com.google.firebase:firebase-messaging")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
// Also put google-services.json (from your Firebase console) in the app folder.

// ---- Project 14 Fitness
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// No library for the sensor: it is part of Android.

// ---- Project 15 Food delivery
implementation("com.google.maps.android:maps-compose:6.2.1")
implementation("com.google.android.gms:play-services-maps:19.0.0")
// Manifest, inside <application>:
// <meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_KEY"/>
// Keep the key out of public git: read it from local.properties with the Secrets Gradle Plugin.

// ---- Project 16 Music player
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")

// ---- Project 17 QR scanner
implementation("androidx.camera:camera-core:1.4.1")
implementation("androidx.camera:camera-camera2:1.4.1")
implementation("androidx.camera:camera-lifecycle:1.4.1")
implementation("androidx.camera:camera-view:1.4.1")
implementation("com.google.mlkit:barcode-scanning:17.3.0")

// ---- Project 18 Banking
implementation("androidx.biometric:biometric:1.1.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")

// ---- Project 19 AI chatbot
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")

// ---- Project 20 Video streaming
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-ui:1.5.0")

// ---- Project 21 Job portal
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
Jobs/app/src/main/java/com/example/jobs/
  JobModels.kt      Job, filter logic, resume rules
  JobApi.kt         Retrofit interface (list, apply)
  JobViewModel.kt   state, filters, upload
  JobScreens.kt     list with chips, detail with upload
  MainActivity.kt
app/src/test/.../JobTest.kt

The code

`app/src/main/java/com/example/jobs/JobModels.kt`

package com.example.jobs

import kotlinx.serialization.Serializable

@Serializable
data class Job(
    val id: String,
    val title: String,
    val company: String,
    val location: String,
    val type: String = "Full-time",       // Full-time, Part-time, Internship, Contract
    val remote: Boolean = false,
    val description: String = "",
)

@Serializable
data class ApplyResult(val ok: Boolean, val message: String = "")

data class JobFilter(
    val query: String = "",
    val remoteOnly: Boolean = false,
    val type: String? = null,
)

// Filtering is done in plain Kotlin, so it is easy to test. A real backend would also filter on the server.
fun List<Job>.filtered(f: JobFilter): List<Job> {
    val q = f.query.trim().lowercase()
    return filter { job ->
        (q.isEmpty() || job.title.lowercase().contains(q) || job.company.lowercase().contains(q) || job.location.lowercase().contains(q)) &&
            (!f.remoteOnly || job.remote) &&
            (f.type == null || job.type == f.type)
    }
}

// Accept only resume files of reasonable size and type before uploading.
fun resumeProblem(mime: String?, sizeBytes: Long): String? {
    val allowed = setOf(
        "application/pdf",
        "application/msword",
        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    )
    return when {
        mime == null || mime !in allowed -> "Choose a PDF or Word file."
        sizeBytes <= 0 -> "The file is empty."
        sizeBytes > 5L * 1024 * 1024 -> "The file must be 5 MB or smaller."
        else -> null
    }
}

`app/src/main/java/com/example/jobs/JobApi.kt`

package com.example.jobs

import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query

// The contract your own backend provides. The job list and applications are stored on the server.
interface JobApi {
    @GET("jobs")
    suspend fun jobs(@Query("page") page: Int): List<Job>

    @Multipart
    @POST("jobs/{id}/apply")
    suspend fun apply(
        @Path("id") id: String,
        @Part("cover") cover: RequestBody,
        @Part resume: MultipartBody.Part,
    ): ApplyResult
}

`app/src/main/java/com/example/jobs/JobViewModel.kt`

package com.example.jobs

import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody

data class JobsState(
    val all: List<Job> = emptyList(),
    val loading: Boolean = true,
    val error: String? = null,
    val filter: JobFilter = JobFilter(),
    val applyMessage: String? = null,
    val applying: Boolean = false,
) {
    val shown: List<Job> get() = all.filtered(filter)
}

class JobViewModel(private val api: JobApi) : ViewModel() {
    private val _state = MutableStateFlow(JobsState())
    val state: StateFlow<JobsState> = _state.asStateFlow()

    init {
        load()
    }

    fun load() {
        _state.update { it.copy(loading = true, error = null) }
        viewModelScope.launch {
            try {
                val jobs = api.jobs(page = 1)
                _state.update { it.copy(all = jobs, loading = false) }
            } catch (e: Exception) {
                _state.update { it.copy(loading = false, error = "Could not load jobs. Pull to try again.") }
            }
        }
    }

    fun setFilter(f: JobFilter) {
        _state.update { it.copy(filter = f) }
    }

    fun apply(resolver: ContentResolver, jobId: String, cover: String, resume: Uri) {
        viewModelScope.launch {
            val mime = resolver.getType(resume)
            val size = withContext(Dispatchers.IO) {
                resolver.query(resume, null, null, null, null)?.use { c ->
                    val col = c.getColumnIndex(OpenableColumns.SIZE)
                    if (c.moveToFirst() && col >= 0) c.getLong(col) else 0L
                } ?: 0L
            }
            val problem = resumeProblem(mime, size)
            if (problem != null) {
                _state.update { it.copy(applyMessage = problem) }
                return@launch
            }
            _state.update { it.copy(applying = true, applyMessage = null) }
            try {
                val bytes = withContext(Dispatchers.IO) { resolver.openInputStream(resume)!!.use { it.readBytes() } }
                val part = MultipartBody.Part.createFormData(
                    "resume",
                    "resume",
                    bytes.toRequestBody(mime!!.toMediaType()),
                )
                val result = api.apply(jobId, cover.take(2000).toRequestBody("text/plain".toMediaType()), part)
                _state.update { it.copy(applying = false, applyMessage = if (result.ok) "Application sent" else result.message) }
            } catch (e: Exception) {
                _state.update { it.copy(applying = false, applyMessage = "Could not send the application. Try again.") }
            }
        }
    }

    fun messageShown() {
        _state.update { it.copy(applyMessage = null) }
    }
}

`app/src/main/java/com/example/jobs/JobScreens.kt`

package com.example.jobs

import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp

@Composable
fun JobApp(vm: JobViewModel) {
    val s by vm.state.collectAsState()
    var selected by remember { mutableStateOf<Job?>(null) }
    val job = selected
    if (job == null) {
        JobList(vm, s, onOpen = { selected = it })
    } else {
        JobDetail(vm, s, job, onBack = {
            selected = null
            vm.messageShown()
        })
    }
}

@Composable
private fun JobList(vm: JobViewModel, s: JobsState, onOpen: (Job) -> Unit) {
    Column(Modifier.fillMaxSize().padding(12.dp)) {
        OutlinedTextField(
            value = s.filter.query,
            onValueChange = { vm.setFilter(s.filter.copy(query = it)) },
            label = { Text("Search title, company or city") },
            modifier = Modifier.fillMaxWidth(),
        )
        Row(Modifier.padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            FilterChip(
                selected = s.filter.remoteOnly,
                onClick = { vm.setFilter(s.filter.copy(remoteOnly = !s.filter.remoteOnly)) },
                label = { Text("Remote") },
            )
            listOf("Full-time", "Internship", "Contract").forEach { t ->
                FilterChip(
                    selected = s.filter.type == t,
                    onClick = { vm.setFilter(s.filter.copy(type = if (s.filter.type == t) null else t)) },
                    label = { Text(t) },
                )
            }
        }
        when {
            s.loading -> CircularProgressIndicator()
            s.error != null -> Column {
                Text(s.error, color = MaterialTheme.colorScheme.error)
                OutlinedButton(onClick = vm::load) { Text("Try again") }
            }
            s.shown.isEmpty() -> Text("No jobs match your filters.")
            else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                items(s.shown, key = { it.id }) { j ->
                    Card(Modifier.fillMaxWidth().clickable { onOpen(j) }) {
                        Column(Modifier.padding(12.dp)) {
                            Text(j.title, style = MaterialTheme.typography.titleMedium)
                            Text(j.company + " · " + j.location + if (j.remote) " · Remote" else "")
                            Text(j.type, style = MaterialTheme.typography.bodySmall)
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun JobDetail(vm: JobViewModel, s: JobsState, job: Job, onBack: () -> Unit) {
    val context = LocalContext.current
    var cover by remember { mutableStateOf("") }
    var resume by remember { mutableStateOf<Uri?>(null) }
    val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { resume = it }

    Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
        OutlinedButton(onClick = onBack) { Text("Back") }
        Text(job.title, style = MaterialTheme.typography.headlineSmall)
        Text(job.company + " · " + job.location)
        Text(job.description)
        OutlinedTextField(cover, { cover = it }, label = { Text("Cover note (optional)") }, modifier = Modifier.fillMaxWidth())
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            OutlinedButton(onClick = {
                picker.launch(arrayOf("application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
            }) { Text(if (resume == null) "Choose resume" else "Resume chosen") }
            Button(
                onClick = { resume?.let { vm.apply(context.contentResolver, job.id, cover, it) } },
                enabled = resume != null && !s.applying,
            ) { Text(if (s.applying) "Sending..." else "Apply") }
        }
        s.applyMessage?.let { Text(it) }
    }
}

`app/src/main/java/com/example/jobs/MainActivity.kt`

package com.example.jobs

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        val api = Retrofit.Builder()
            .baseUrl("https://your-jobs-backend.example.com/")   // replace with your own server
            .addConverterFactory(Json { ignoreUnknownKeys = true }.asConverterFactory("application/json".toMediaType()))
            .build()
            .create(JobApi::class.java)

        setContent {
            MaterialTheme {
                Surface(Modifier.systemBarsPadding()) {
                    val vm: JobViewModel = viewModel(
                        factory = object : ViewModelProvider.Factory {
                            @Suppress("UNCHECKED_CAST")
                            override fun <T : ViewModel> create(modelClass: Class<T>): T {
                                return JobViewModel(api) as T
                            }
                        },
                    )
                    JobApp(vm)
                }
            }
        }
    }
}

`app/src/test/java/com/example/jobs/JobTest.kt`

package com.example.jobs

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test

class JobTest {
    private val jobs = listOf(
        Job("1", "Android Developer", "Acme", "Pune", "Full-time", remote = false),
        Job("2", "Backend Intern", "Globex", "Remote", "Internship", remote = true),
        Job("3", "Kotlin Engineer", "Initech", "Delhi", "Contract", remote = true),
    )

    @Test
    fun searchMatchesTitleCompanyAndCity() {
        assertEquals(1, jobs.filtered(JobFilter(query = "acme")).size)
        assertEquals(1, jobs.filtered(JobFilter(query = "delhi")).size)
        assertEquals(3, jobs.filtered(JobFilter(query = "e")).size)
    }

    @Test
    fun remoteAndTypeFiltersCombine() {
        val r = jobs.filtered(JobFilter(remoteOnly = true, type = "Contract"))
        assertEquals(listOf("3"), r.map { it.id })
    }

    @Test
    fun resumeRules() {
        assertNull(resumeProblem("application/pdf", 1000))
        assertNotNull(resumeProblem("image/png", 1000))
        assertNotNull(resumeProblem("application/pdf", 6L * 1024 * 1024))
        assertNotNull(resumeProblem("application/pdf", 0))
    }
}

How it works

  • The ViewModel loads the first page of jobs and keeps the filter in state. Every change to the filter recalculates shown.
  • The list screen shows a spinner while loading, an error with Try again on failure, a message when nothing matches, and the cards otherwise.
  • The detail screen opens the system file picker for PDF and Word files. The Apply button is disabled until a file is chosen and while sending.
  • Before upload, the ViewModel reads the file's type and size, applies resumeProblem, and shows a clear message if it fails. Otherwise it reads the bytes and sends a multipart request.

Expected output

The list shows a search box, chips for Remote, Full-time, Internship and Contract, and a card for each job with title, company, place and type. Type "kotlin": the list narrows to matching jobs. Turn on Remote and Contract together: only jobs that are both remain. Open a job: you see the description, a cover note box, Choose resume and Apply. Choose a PNG file and press Apply: the message "Choose a PDF or Word file." appears. Choose a PDF under 5 MB: Apply sends it and shows "Application sent". Without a backend, the list shows "Could not load jobs. Pull to try again." with a Try again button.

Your task

  1. Build the app and test the filters with sample data from a mock server.
  2. Add sign-in and send the token in an interceptor.
  3. Add paging with Paging 3 and a page parameter.
  4. Save jobs for later in Room and add a Saved tab.

How to check it worked

  • Filters combine correctly.
  • Wrong file types and big files are refused before upload.
  • You can explain why the server must validate the resume again.

Frequently asked questions

Why does OpenDocument need no permission?

The system file picker runs outside your app and returns access only to the file the user chose, which is safer than broad storage access.

Why validate the file twice?

The app check gives quick feedback, but anyone can bypass the app. The server must check type, size and content itself.

Where should filtering happen?

On the server for large lists, since you cannot download everything. For small lists, a pure Kotlin function on the device is fine and easy to test.