Android App from Your API
Estimated time: 6–8 hours | Difficulty: Advanced
What You Will Learn
- Set up Android Studio and create a new Android project from scratch
- Learn the basics of Kotlin by comparing it side-by-side with Java
- Build a job search screen with a list that displays results from your API
- Connect an Android app to your existing Spring Boot backend using Retrofit
- Implement a favorites feature with bottom navigation, saving, and swipe-to-delete
- Polish the app with pull-to-refresh, loading indicators, error handling, and Material Design
- Understand why building proper APIs enables multiple clients to share one backend
1. What is Android Development?
You have built a Spring Boot backend. You have built a web frontend that talks to it. Now you are going to build something that most beginners never attempt: a real Android app that connects to the exact same API. Your backend does not change. Not a single line. The server that powers your website is about to power a mobile app too.
Android apps are written in Kotlin. If you have been following this course, you already know Java. The good news is that Kotlin was designed by JetBrains (the same company that makes IntelliJ) to be a better Java. It runs on the same JVM. It uses the same libraries. It interoperates with Java code seamlessly. If you know Java, Kotlin is going to feel like Java with less boilerplate and more convenience. You will not be starting from zero — you will be building on everything you already know.
Before Kotlin became the standard, Android apps were written entirely in Java. Google officially adopted Kotlin as the preferred language for Android development in 2019, and today the vast majority of new Android projects use it. But because Kotlin is so close to Java, and because you already understand object-oriented programming, classes, interfaces, and all the fundamentals — learning Kotlin is more like learning a dialect than learning a new language.
Android apps are built using Android Studio, which is free and based on IntelliJ IDEA — the same IDE family as the tools you have been using. If you have used IntelliJ for your Spring Boot projects, Android Studio will feel instantly familiar. The menus, the project structure panel, the code editor, the keyboard shortcuts — it is all the same foundation with Android-specific tools layered on top.
Why This Matters
The entire point of building a proper REST API is that any client can consume it. Your web frontend sends HTTP requests and gets JSON back. An Android app can send the exact same HTTP requests and get the exact same JSON back. An iOS app could do it too. A desktop app. A command-line tool. The backend does not care who is asking — it just responds to well-formed requests. This is why we spent so much time building clean API endpoints in the Resumator track. This side quest is the payoff.
Think about how real companies work. A company like Spotify has a website, an Android app, an iOS app, a desktop app, and embedded apps in cars and smart speakers. They do not have five completely separate backends. They have APIs that all of these clients connect to. The data is the same. The business logic is the same. Only the user interface changes. That is exactly what you are about to experience on a smaller scale: same API, same database, brand new interface.
By the end of this side quest, you will have a working Android application running on an emulator (or your own phone) that searches for jobs and manages favorites — all powered by the Spring Boot backend you already built. Let us get started.
2. Setting Up Android Studio
First, you need to download and install Android Studio. Go to developer.android.com/studio and download the latest stable version. The installer is straightforward — accept the defaults on every screen. It will download the Android SDK, build tools, and an emulator image. This takes a while, so be patient. On a typical internet connection, expect 10–15 minutes for the initial setup.
Once Android Studio is installed and open, create a new project:
- Click New Project
- Select Empty Activity as the template (not "Empty Compose Activity" — we are using the traditional View system for this project)
- Set the project name to Resumator Mobile
- Set the package name to
com.example.resumatormobile - Set the language to Kotlin
- Set the minimum SDK to API 26 (Android 8.0 Oreo)
- Click Finish
API 26 is a good minimum because it covers over 95% of active Android devices while still giving you access to modern APIs. Setting the minimum too low forces you to write compatibility code for ancient devices. Setting it too high means your app will not run on older phones. API 26 hits the sweet spot.
After the project is created, Android Studio will spend a minute or two downloading dependencies and indexing files. Wait until the progress bar at the bottom disappears before doing anything else. Once it finishes, let us take a tour of the project structure.
Project Structure
Android projects look different from Spring Boot projects, but the concepts are similar. Here is what matters:
app/src/main/java/com/example/resumatormobile/— Your Kotlin source code lives here. This is like yoursrc/main/javain Spring Boot.app/src/main/res/layout/— XML files that define your user interface. Think of these as Android's version of HTML — they describe what the screen looks like.app/src/main/AndroidManifest.xml— The app's configuration file. It declares permissions (like internet access), activities (screens), and other app-level settings.app/build.gradle— The dependency file, similar to Maven'spom.xml. This is where you add libraries like Retrofit (for HTTP requests).MainActivity.kt— The entry point of your app. This is the first screen the user sees when they open the app.
Now you need to decide how to run the app. You have two options:
Option 1: The Android Emulator. Android Studio includes a built-in emulator that simulates a phone on your computer. Go to Tools → Device Manager and create a new virtual device. Choose a Pixel 6 (or any recent phone), select an API 34 system image, and finish the wizard. The emulator takes significant RAM (at least 4 GB), so if your computer is low on memory, consider option 2.
Option 2: Your Physical Phone. If you have an Android phone, you can run your app directly on it. Enable Developer Options on your phone (Settings → About Phone → tap "Build Number" seven times), then enable USB Debugging under Developer Options. Connect your phone via USB, and Android Studio will detect it automatically. This is faster than the emulator and lets you test on a real device.
Either option works. For this side quest, we will write instructions assuming the emulator, but everything applies to a physical device as well.
Before writing any code, let us make sure the project compiles. Click the green Run button (or press Shift+F10). The app should build, the emulator should start, and you should see a screen that says "Hello Android!" or similar. If you see that, your environment is ready. If you get errors, check that your SDK is installed correctly and that your emulator image matches your target API level.
3. Kotlin Crash Course
Before we start building the app, you need to understand Kotlin. The good news is that you already know the hard parts. Object-oriented programming, classes, interfaces, inheritance, loops, conditionals, collections — all of these concepts are identical in Kotlin. What changes is the syntax. Kotlin is more concise, more expressive, and has several features that eliminate common Java pain points.
Let us look at the key differences side by side. In Java, you might write:
// Java
String name = "Coders Farm";
final int maxResults = 10;
List<String> items = new ArrayList<>();
In Kotlin, the same code looks like this:
// Kotlin
var name: String = "Coders Farm" // var = mutable (can change)
val maxResults: Int = 10 // val = immutable (like final)
val items: List<String> = listOf() // no 'new' keyword needed
Notice the differences: val replaces final, var replaces a regular variable declaration, the type comes after the name (separated by a colon), and you do not need the new keyword. Kotlin also has type inference, so you can often omit the type entirely:
// Kotlin with type inference
var name = "Coders Farm" // compiler knows this is a String
val maxResults = 10 // compiler knows this is an Int
val items = listOf<String>() // compiler knows the list type
One of Kotlin's most important features is null safety. In Java, any variable can be null, and if you forget to check, you get a NullPointerException at runtime. Kotlin fixes this at the language level:
// Null safety in Kotlin
var name: String = "Coders Farm" // cannot be null
var nickname: String? = null // the '?' means it CAN be null
// Safe call operator — only calls .length if nickname is not null
val length = nickname?.length // returns null instead of crashing
// Elvis operator — provide a default if the value is null
val displayName = nickname ?: "Anonymous" // "Anonymous" if nickname is null
// Non-null assertion — crashes if null (use sparingly!)
val riskyLength = nickname!!.length // throws exception if null
Null Safety
In Java, NullPointerException is the single most common runtime error. Kotlin eliminates most of these errors at compile time. If a variable's type does not have a ?, the compiler guarantees it can never be null. If it does have a ?, the compiler forces you to handle the null case before using it. This is one of the biggest productivity improvements Kotlin offers over Java.
Kotlin also has data classes, which eliminate the massive boilerplate of Java POJOs. In Java, a simple class with three fields requires a constructor, getters, setters, equals(), hashCode(), and toString() — easily 50+ lines. In Kotlin:
// A complete data class in Kotlin — one line
data class Job(
val title: String,
val company: String,
val salary: Double?
)
// That single declaration gives you:
// - A constructor
// - Getters for all properties
// - equals() and hashCode() based on all properties
// - toString() that prints all properties
// - copy() for creating modified copies
val job = Job("Android Developer", "Google", 150000.0)
println(job) // prints: Job(title=Android Developer, company=Google, salary=150000.0)
val remote = job.copy(company = "Remote Inc") // copy with one field changed
Finally, lambdas in Kotlin are cleaner than in Java. You will use them constantly in Android development, especially for click listeners and API callbacks:
// Java lambda
button.setOnClickListener(view -> {
System.out.println("Clicked!");
});
// Kotlin lambda — shorter, cleaner
button.setOnClickListener {
println("Clicked!")
}
// Kotlin lambda with collections
val names = listOf("Alice", "Bob", "Charlie")
val upper = names.map { it.uppercase() } // ["ALICE", "BOB", "CHARLIE"]
val long = names.filter { it.length > 3 } // ["Alice", "Charlie"]
In Kotlin, if a lambda is the last parameter of a function, you can move it outside the parentheses. If it is the only parameter, you can omit the parentheses entirely. The implicit parameter it refers to the single argument of a lambda. These conventions make Kotlin code dramatically more concise than the equivalent Java.
You do not need to memorize every detail right now. As you build the app, you will use these features repeatedly, and they will become second nature. The key takeaway: if you know Java, you already know 80% of Kotlin. The remaining 20% is syntactic sugar that makes your code shorter and safer.
4. Building the Job Search Screen
Now let us build the main screen of the app: a job search interface. The user types a keyword and a location, taps a search button, and sees a scrollable list of job results. This is the Android equivalent of the search page you built for the web frontend — same data, different platform.
Android user interfaces are defined in XML layout files. Think of XML layouts as Android's version of HTML. Instead of <div> and <span>, you use <LinearLayout>, <EditText>, and <RecyclerView>. The concepts are the same: you describe a hierarchy of elements that the system renders on screen.
Create a layout file at app/src/main/res/layout/fragment_search.xml. This defines the search screen:
<?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">
<!-- Search keyword input -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Job title or keyword">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editKeyword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Location input -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="City or state">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editLocation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Search button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Search Jobs" />
<!-- Loading indicator -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="16dp"
android:visibility="gone" />
<!-- Results list -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerJobs"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="12dp" />
</LinearLayout>
Let us break down what each piece does. The root <LinearLayout> arranges all children vertically (one below the other), similar to a <div> with display: flex; flex-direction: column in CSS. The TextInputLayout and TextInputEditText are Material Design components that provide text fields with floating hint labels — the hint text floats above the field when the user starts typing, which is the standard Android pattern. The MaterialButton is a styled button that follows Material Design guidelines. The ProgressBar is a spinning loading indicator that starts hidden (visibility="gone") and becomes visible while a network request is in progress. The RecyclerView is Android's high-performance scrollable list component, similar to rendering a <ul> full of job cards in HTML.
Now you need a layout for each individual job item in the list. Create app/src/main/res/layout/item_job.xml:
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardElevation="2dp"
app:cardCornerRadius="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/textJobTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/textCompany"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="14sp"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/textLocation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="13sp"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/textSalary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?attr/colorPrimary" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
Each job in the search results will be displayed as a Material Design card with the title, company, location, and salary. Now you need the Kotlin code that connects data to this layout. This is called an Adapter — it adapts your data (a list of jobs) into views (visual cards on screen).
Create a new Kotlin file called JobAdapter.kt in your main package:
package com.example.resumatormobile
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
class JobAdapter(
private var jobs: List<Job> = emptyList(),
private val onFavoriteClick: (Job) -> Unit
) : RecyclerView.Adapter<JobAdapter.JobViewHolder>() {
// ViewHolder holds references to the views in each list item
class JobViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val textTitle: TextView = view.findViewById(R.id.textJobTitle)
val textCompany: TextView = view.findViewById(R.id.textCompany)
val textLocation: TextView = view.findViewById(R.id.textLocation)
val textSalary: TextView = view.findViewById(R.id.textSalary)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): JobViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_job, parent, false)
return JobViewHolder(view)
}
override fun onBindViewHolder(holder: JobViewHolder, position: Int) {
val job = jobs[position]
holder.textTitle.text = job.jobTitle
holder.textCompany.text = job.employerName
holder.textLocation.text = buildString {
if (!job.jobCity.isNullOrBlank()) append(job.jobCity)
if (!job.jobState.isNullOrBlank()) {
if (isNotEmpty()) append(", ")
append(job.jobState)
}
}.ifEmpty { "Location not specified" }
holder.textSalary.text = formatSalary(job.jobMinSalary, job.jobMaxSalary)
// Long press to favorite
holder.itemView.setOnLongClickListener {
onFavoriteClick(job)
true
}
}
override fun getItemCount(): Int = jobs.size
fun updateJobs(newJobs: List<Job>) {
jobs = newJobs
notifyDataSetChanged()
}
private fun formatSalary(min: Double?, max: Double?): String {
if (min == null && max == null) return "Salary not listed"
if (min != null && max != null) return "$${"%,.0f".format(min)} – $${"%,.0f".format(max)}"
if (min != null) return "From $${"%,.0f".format(min)}"
return "Up to $${"%,.0f".format(max)}"
}
}
This adapter follows the same pattern you would use in any Android app. The RecyclerView calls onCreateViewHolder to create a new card and onBindViewHolder to fill it with data. The ViewHolder pattern caches references to views so Android does not have to look them up every time the list scrolls. The onFavoriteClick lambda is a callback — when the user long-presses a job card, the adapter calls this function, and the activity or fragment decides what to do (save it as a favorite).
You also need the Job data class that represents a job from the API. Create Job.kt:
package com.example.resumatormobile
import com.google.gson.annotations.SerializedName
data class Job(
@SerializedName("job_id") val jobId: String,
@SerializedName("job_title") val jobTitle: String,
@SerializedName("employer_name") val employerName: String,
@SerializedName("employer_logo") val employerLogo: String?,
@SerializedName("job_city") val jobCity: String?,
@SerializedName("job_state") val jobState: String?,
@SerializedName("job_description") val jobDescription: String?,
@SerializedName("job_apply_link") val jobApplyLink: String?,
@SerializedName("job_min_salary") val jobMinSalary: Double?,
@SerializedName("job_max_salary") val jobMaxSalary: Double?
)
The @SerializedName annotations tell the Gson JSON library how to map JSON field names (snake_case from the API) to Kotlin property names (camelCase). This is the Kotlin equivalent of what Jackson does automatically in Spring Boot. Notice how the nullable types (String?, Double?) perfectly express which fields might be missing from the API response — this is Kotlin's null safety in action.
5. Connecting to Your API
This is where it gets exciting. Your Android app is about to talk to the Spring Boot backend you already built. The tool that makes this easy is called Retrofit — the most popular HTTP client library for Android. Retrofit lets you define API endpoints as a Kotlin interface, and it generates all the networking code for you. If you liked how Spring Data JPA generates repository implementations from interface method names, you will love Retrofit.
First, add the required dependencies to your app/build.gradle file. In the dependencies block, add:
// Retrofit for HTTP requests
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// Gson for JSON parsing
implementation("com.google.code.gson:gson:2.10.1")
// Material Design components
implementation("com.google.android.material:material:1.11.0")
// RecyclerView and SwipeRefreshLayout
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
After adding these lines, click Sync Now in the bar that appears at the top of the editor. Gradle will download the libraries. You also need to add internet permission to your AndroidManifest.xml file, just before the <application> tag:
<uses-permission android:name="android.permission.INTERNET" />
Now define the API interface. Create ResumeApiService.kt:
package com.example.resumatormobile
import retrofit2.Call
import retrofit2.http.*
data class SearchResponse(val data: List<Job>)
data class FavoriteJob(
val id: Long? = null,
val jobId: String,
val jobTitle: String,
val employerName: String,
val employerLogo: String?,
val jobCity: String?,
val jobState: String?,
val jobDescription: String?,
val jobApplyLink: String?,
val jobMinSalary: Double?,
val jobMaxSalary: Double?,
val notes: String? = null
)
interface ResumeApiService {
@GET("/api/jobs/search")
fun searchJobs(
@Query("query") query: String,
@Query("location") location: String
): Call<SearchResponse>
@GET("/api/favorites")
fun getFavorites(): Call<List<FavoriteJob>>
@POST("/api/favorites")
fun saveFavorite(@Body favorite: FavoriteJob): Call<FavoriteJob>
@DELETE("/api/favorites/{id}")
fun deleteFavorite(@Path("id") id: Long): Call<Void>
}
Look at how clean this is. Each method in the interface corresponds to an endpoint on your Spring Boot server. The annotations (@GET, @POST, @DELETE) specify the HTTP method. The @Query annotation adds query parameters to the URL. The @Body annotation sends a JSON object in the request body. The @Path annotation fills in URL path variables. Retrofit reads these annotations and generates all the HTTP code at runtime.
Now create a singleton object that configures Retrofit and provides the API service. Create ApiClient.kt:
package com.example.resumatormobile
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object ApiClient {
// IMPORTANT: 10.0.2.2 is how the Android emulator reaches
// your computer's localhost. Your Spring Boot server runs
// on localhost:8080, but the emulator is a separate virtual
// device — "localhost" inside the emulator means the emulator
// itself, not your computer. 10.0.2.2 is the special alias
// that routes to your computer's loopback address.
private const val BASE_URL = "http://10.0.2.2:8080/"
private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
val service: ResumeApiService = retrofit.create(ResumeApiService::class.java)
}
localhost or 127.0.0.1 in your app, it refers to the emulator itself, not your computer. Your Spring Boot server is running on your computer, not inside the emulator. The address 10.0.2.2 is a special alias built into the Android emulator that routes network traffic to your computer's loopback address. This is a common "gotcha" that trips up every Android developer at least once. If you are running on a physical device instead, you need to use your computer's local IP address (like 192.168.1.x) and make sure both devices are on the same Wi-Fi network.
Now let us wire everything together. In your search fragment or activity, make the API call when the user taps the search button:
// Inside your SearchFragment or MainActivity
btnSearch.setOnClickListener {
val keyword = editKeyword.text.toString().trim()
val location = editLocation.text.toString().trim()
if (keyword.isEmpty()) {
editKeyword.error = "Enter a keyword"
return@setOnClickListener
}
// Show loading, hide results
progressBar.visibility = View.VISIBLE
recyclerJobs.visibility = View.GONE
ApiClient.service.searchJobs(keyword, location)
.enqueue(object : retrofit2.Callback<SearchResponse> {
override fun onResponse(
call: retrofit2.Call<SearchResponse>,
response: retrofit2.Response<SearchResponse>
) {
progressBar.visibility = View.GONE
recyclerJobs.visibility = View.VISIBLE
if (response.isSuccessful) {
val jobs = response.body()?.data ?: emptyList()
jobAdapter.updateJobs(jobs)
if (jobs.isEmpty()) {
showMessage("No jobs found. Try different keywords.")
}
} else {
showMessage("Search failed: ${response.code()}")
}
}
override fun onFailure(
call: retrofit2.Call<SearchResponse>,
t: Throwable
) {
progressBar.visibility = View.GONE
recyclerJobs.visibility = View.VISIBLE
showMessage("Network error: ${t.message}")
}
})
}
This is the Retrofit callback pattern. The enqueue method sends the HTTP request on a background thread (Android does not allow network calls on the main thread — it would freeze the UI). When the response arrives, Retrofit calls either onResponse (the server responded) or onFailure (the request could not be completed — no network, server down, timeout, etc.). Inside onResponse, we check response.isSuccessful to confirm the HTTP status code was in the 200 range, then extract the list of jobs from the response body.
Before testing, make sure your Spring Boot backend is running. Start it up the same way you always do. Then run your Android app on the emulator. Type a search query, tap the button, and watch the results appear. You are now looking at data from your MySQL database, served by your Spring Boot API, displayed in an Android app on a virtual phone. That is a full-stack mobile experience.
http://localhost:8080 on your computer. (2) You are using 10.0.2.2 as the base URL, not localhost. (3) You added the INTERNET permission to your AndroidManifest.xml. If you are on Android 9 (API 28) or higher and using HTTP instead of HTTPS, you may also need to add android:usesCleartextTraffic="true" to the <application> tag in your manifest. This allows unencrypted HTTP traffic, which is fine for local development but should not be used in a production app.
6. Favorites
Now let us add the ability to save and manage favorite jobs. You will add a bottom navigation bar with two tabs — Search and Favorites — so the user can switch between searching for jobs and viewing their saved list. This is the same favorites feature you built on the web, but now on Android.
First, set up bottom navigation. Add this to your main activity layout (activity_main.xml), below the fragment container:
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
app:menu="@menu/bottom_nav_menu" />
Create the menu file at app/src/main/res/menu/bottom_nav_menu.xml:
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_search"
android:icon="@drawable/ic_search"
android:title="Search" />
<item
android:id="@+id/nav_favorites"
android:icon="@drawable/ic_favorite"
android:title="Favorites" />
</menu>
Now build the favorites functionality. Create FavoritesFragment.kt (or add it to your existing activity). This screen fetches favorites from your API and displays them in a list:
package com.example.resumatormobile
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
class FavoritesFragment : Fragment() {
private lateinit var recyclerFavorites: RecyclerView
private lateinit var progressBar: View
private lateinit var emptyView: View
private val favorites = mutableListOf<FavoriteJob>()
private lateinit var adapter: FavoriteAdapter
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_favorites, container, false)
recyclerFavorites = view.findViewById(R.id.recyclerFavorites)
progressBar = view.findViewById(R.id.progressBar)
emptyView = view.findViewById(R.id.emptyView)
adapter = FavoriteAdapter(favorites)
recyclerFavorites.layoutManager = LinearLayoutManager(requireContext())
recyclerFavorites.adapter = adapter
// Swipe to delete
val swipeHandler = object : ItemTouchHelper.SimpleCallback(
0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
) {
override fun onMove(
rv: RecyclerView, vh: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
) = false
override fun onSwiped(vh: RecyclerView.ViewHolder, dir: Int) {
val position = vh.adapterPosition
val favorite = favorites[position]
deleteFavorite(favorite.id!!, position)
}
}
ItemTouchHelper(swipeHandler).attachToRecyclerView(recyclerFavorites)
loadFavorites()
return view
}
private fun loadFavorites() {
progressBar.visibility = View.VISIBLE
recyclerFavorites.visibility = View.GONE
emptyView.visibility = View.GONE
ApiClient.service.getFavorites()
.enqueue(object : retrofit2.Callback<List<FavoriteJob>> {
override fun onResponse(
call: retrofit2.Call<List<FavoriteJob>>,
response: retrofit2.Response<List<FavoriteJob>>
) {
progressBar.visibility = View.GONE
if (response.isSuccessful) {
favorites.clear()
favorites.addAll(response.body() ?: emptyList())
adapter.notifyDataSetChanged()
if (favorites.isEmpty()) {
emptyView.visibility = View.VISIBLE
} else {
recyclerFavorites.visibility = View.VISIBLE
}
}
}
override fun onFailure(
call: retrofit2.Call<List<FavoriteJob>>,
t: Throwable
) {
progressBar.visibility = View.GONE
Toast.makeText(
requireContext(),
"Failed to load favorites: ${t.message}",
Toast.LENGTH_LONG
).show()
}
})
}
private fun deleteFavorite(id: Long, position: Int) {
ApiClient.service.deleteFavorite(id)
.enqueue(object : retrofit2.Callback<Void> {
override fun onResponse(
call: retrofit2.Call<Void>,
response: retrofit2.Response<Void>
) {
if (response.isSuccessful) {
favorites.removeAt(position)
adapter.notifyItemRemoved(position)
Toast.makeText(
requireContext(),
"Favorite removed",
Toast.LENGTH_SHORT
).show()
if (favorites.isEmpty()) {
recyclerFavorites.visibility = View.GONE
emptyView.visibility = View.VISIBLE
}
}
}
override fun onFailure(
call: retrofit2.Call<Void>,
t: Throwable
) {
// Undo the swipe by re-inserting the item
adapter.notifyItemChanged(position)
Toast.makeText(
requireContext(),
"Failed to delete: ${t.message}",
Toast.LENGTH_LONG
).show()
}
})
}
}
The swipe-to-delete feature uses ItemTouchHelper, which is Android's built-in way to add swipe and drag gestures to a RecyclerView. When the user swipes a favorite left or right, onSwiped fires, and we call the DELETE endpoint on our API. If the delete succeeds, we remove the item from the local list and animate it out. If it fails, we undo the swipe so the item reappears.
Now add the favorite button to the search results. When the user long-presses a job card in the search results, the app saves it as a favorite:
// Saving a job as a favorite (called from the adapter's onFavoriteClick)
private fun saveAsFavorite(job: Job) {
val favorite = FavoriteJob(
jobId = job.jobId,
jobTitle = job.jobTitle,
employerName = job.employerName,
employerLogo = job.employerLogo,
jobCity = job.jobCity,
jobState = job.jobState,
jobDescription = job.jobDescription,
jobApplyLink = job.jobApplyLink,
jobMinSalary = job.jobMinSalary,
jobMaxSalary = job.jobMaxSalary
)
ApiClient.service.saveFavorite(favorite)
.enqueue(object : retrofit2.Callback<FavoriteJob> {
override fun onResponse(
call: retrofit2.Call<FavoriteJob>,
response: retrofit2.Response<FavoriteJob>
) {
if (response.isSuccessful) {
Toast.makeText(
requireContext(),
"${job.jobTitle} saved to favorites!",
Toast.LENGTH_SHORT
).show()
} else if (response.code() == 409) {
Toast.makeText(
requireContext(),
"Already in favorites",
Toast.LENGTH_SHORT
).show()
}
}
override fun onFailure(
call: retrofit2.Call<FavoriteJob>,
t: Throwable
) {
Toast.makeText(
requireContext(),
"Failed to save: ${t.message}",
Toast.LENGTH_LONG
).show()
}
})
}
Notice how the 409 (Conflict) status code is handled. Remember back in the Resumator web lessons, you built the FavoriteController to return a 409 when a user tries to save a job they have already favorited. That same logic applies here. The backend does not care whether the request came from a browser or an Android app — the rules are the same.
7. Polish
Your app works, but it does not feel professional yet. Professional Android apps have polish: smooth loading states, pull-to-refresh, graceful error handling, consistent theming, and a proper app icon. Let us add these finishing touches.
Pull-to-refresh. Users expect to be able to pull down on a list to refresh its contents. Android provides the SwipeRefreshLayout component for exactly this. Wrap your RecyclerView in a SwipeRefreshLayout in your layout XML:
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerFavorites"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
Then in your Kotlin code, set up the refresh listener:
swipeRefresh.setOnRefreshListener {
loadFavorites()
}
// In your loadFavorites() onResponse/onFailure callbacks, add:
swipeRefresh.isRefreshing = false
Loading indicators. You already have a ProgressBar that shows while data loads. Make sure it appears at the right times: visible when a request starts, hidden when it finishes (whether success or failure). Never leave the user staring at a blank screen with no indication that something is happening.
Error handling. Network requests can fail for many reasons: the server might be down, the phone might lose Wi-Fi, the request might time out. Always handle the onFailure callback and show a user-friendly message. Never show raw exception messages to the user — translate them into something helpful:
private fun showError(throwable: Throwable) {
val message = when (throwable) {
is java.net.ConnectException ->
"Cannot connect to server. Make sure your backend is running."
is java.net.SocketTimeoutException ->
"Request timed out. Check your connection and try again."
is java.net.UnknownHostException ->
"No internet connection."
else ->
"Something went wrong. Please try again."
}
Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show()
}
Material Design theming. Android's Material Design system provides a consistent, professional look. Update your app/src/main/res/values/themes.xml to customize the color scheme:
<resources>
<style name="Theme.ResumatorMobile"
parent="Theme.Material3.DayNight.DarkActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryVariant">@color/primary_dark</item>
<item name="colorOnPrimary">@color/white</item>
<item name="colorSecondary">@color/accent</item>
</style>
</resources>
Define your colors in app/src/main/res/values/colors.xml:
<resources>
<color name="primary">#1565C0</color>
<color name="primary_dark">#0D47A1</color>
<color name="accent">#FF6F00</color>
<color name="white">#FFFFFF</color>
</resources>
App icon. The default Android app icon is the green robot. To make your app look real, you need a custom icon. In Android Studio, right-click the res folder, choose New → Image Asset, and use the wizard to create an adaptive icon. You can use a simple text-based icon with your app's initials ("RM" for Resumator Mobile) or any image you like. The wizard generates all the required sizes automatically — Android needs icons in multiple resolutions for different screen densities.
These polish details might seem minor, but they are the difference between a demo and a product. Professional apps handle errors gracefully, provide visual feedback during loading, and look consistent throughout. Take the time to get these right.
8. The Big Picture
Step back and look at what you have built. You now have a Spring Boot server with a REST API, a MySQL database, a web frontend, and an Android app. The server does not know or care who is asking. When the web frontend sends GET /api/jobs/search?query=java&location=texas, the server responds with JSON. When the Android app sends the exact same request, the server responds with the exact same JSON. Two completely different clients, one backend, one database, one source of truth.
This is Why Separation Matters
In the early lessons, we talked about separation of concerns — keeping the API layer separate from the frontend. At the time, it might have felt academic. Now you can see exactly why it matters. Because the backend is a clean API that speaks HTTP and JSON, you were able to build an entirely new client without changing a single line of server code. If you had built the web frontend with server-side rendering (where the HTML is generated on the server), you could not have reused any of it for Android. The separation is what made this possible.
In a real company, this architecture scales to entire teams. Imagine a company building a job search platform. There is a backend team that maintains the API, the database, the business logic, and the infrastructure. There is a web frontend team that builds the browser experience using React or Angular or plain HTML and JavaScript. There is an Android team that builds the Android app in Kotlin. There is an iOS team that builds the iPhone app in Swift. All four teams work independently, on their own schedules, using their own tools and languages. The only thing they share is the API contract — the agreed-upon set of endpoints, request formats, and response formats.
When the backend team adds a new endpoint, all three frontend teams can start using it. When the Android team redesigns the search screen, the backend does not need to change. When the iOS team adds a new feature, the Android and web teams are unaffected. This independence is what makes large-scale software development possible. It is why companies can have hundreds of developers working on the same product without stepping on each other's toes.
Think about what you could build next. The same API could power an iOS app written in Swift. It could power a command-line tool written in Python. It could power a Slack bot, a Chrome extension, or a smart TV app. The API is the foundation, and every new client you build on top of it reinforces why you built it right in the first place.
You started this course learning what a variable is. Now you have a multi-platform system: a Java backend, a SQL database, a web frontend, and a Kotlin Android app, all working together. That is not a tutorial project. That is real software engineering.
Knowledge Check
1. When running an Android app on the emulator, why do you use 10.0.2.2 instead of localhost to reach your Spring Boot server?
localhost or 127.0.0.1, it points to the emulator itself — not the computer running Android Studio. The address 10.0.2.2 is a special alias built into the emulator that routes traffic to the host machine's loopback interface, allowing the app to reach your Spring Boot server running on localhost:8080.2. What is the main advantage of Kotlin's null safety system compared to Java?
String?) and non-nullable types (String) at the language level. The compiler will refuse to compile code that uses a nullable value without first handling the null case, whether through safe calls (?.), elvis operators (?:), or explicit checks. This catches most NullPointerException bugs at compile time rather than at runtime, which is one of the biggest productivity improvements Kotlin offers over Java.3. Why can both the web frontend and the Android app use the same Spring Boot backend without any changes to the server code?
Deliverable
Your completed side quest should produce a working Android application running on the emulator (or a physical device) that does the following:
- Connects to your existing Resumator Spring Boot API — no changes to the backend
- Has a search screen where users can enter a keyword and location to find jobs
- Displays search results in a scrollable list with job title, company, location, and salary
- Allows long-pressing a search result to save it as a favorite
- Has a favorites screen accessible via bottom navigation that shows all saved jobs
- Supports swipe-to-delete for removing favorites
- Handles loading states, empty states, and error states gracefully
- Uses Material Design theming for a professional look and feel
When you can search for jobs on your phone (or emulator), save favorites, switch to the favorites tab, see them listed there, and swipe to delete — all powered by the same backend that serves your web app — this side quest is complete. You have built a multi-platform system. That is real software engineering.
Finished this side quest?