Techbase Academy
Android · Lesson 62

The Data Layer: Repositories, Data Sources and Source of Truth

13 phasesA Tested Payments RepositoryAndroid Studio
Phase 1 of 13
Lesson Introduction

The data layer contains your app's data and business rules about that data. It decides how data is created, stored and changed, and it hides where the data comes from: a network server, a local database, a file, a sensor or memory. The UI layer never needs to know. It asks a repository for data and receives it.

This lesson covers Data Layer and The Data layer - MAD Skills from the Modern Android App Architecture programme (https://developer.android.com/courses/android-architecture), based on the official page https://developer.android.com/topic/architecture/data-layer and the recommendations at https://developer.android.com/topic/architecture/recommendations.

By the end you will be able to:

  • Explain the roles of repositories and data sources.
  • Expose immutable data through Flow and suspend functions.
  • Design models per layer (network, database, domain) and map between them.
  • Handle errors with Result or sealed types, and keep threading correct.
  • Implement a repository that combines Retrofit and Room.
  • Add in-memory caching and multiple data sources.
  • Write fakes and test repositories properly.

Big idea: a repository is a promise: "ask me for data and I will give you the best I have, without telling you how I got it".

Honesty note: Room 3 package names are used below, as in lesson 34. If your project uses Room 2.x, change androidx.room3 to androidx.room and follow the mapping in lesson 34.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 13
Prerequisite Concepts
  • Layers and the dependency rule (lessons 60 and 61).
  • Room, DAOs and Flow (lesson 34).
  • Retrofit and Kotlin serialization (lesson 29).
  • Coroutines, dispatchers and Flow (lesson 33).
  • Interfaces and fakes (lesson 60).
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 13
Conceptual Understanding: Repositories and Data Sources
text
ViewModel / Use case
        │ asks
        ▼
  Repository           ← one per type of data (Students, Payments)
   │          │
   ▼          ▼
Local data   Remote data
source       source
(Room DAO)   (Retrofit service)
Piece Responsibility Never
Data source Works with one source (a DAO, a service, a file, DataStore) Combine other sources or know the UI
Repository Exposes data to the rest of the app, resolves conflicts between sources, holds business rules about data, chooses threads Expose DAOs, DTOs or Cursors; reference ViewModels

The official guidance lists what a repository does:

  • Exposes data to the rest of the app.
  • Centralises changes to the data.
  • Resolves conflicts between multiple data sources.
  • Abstracts sources from the rest of the app.
  • Contains business logic that concerns the data.

One repository per type of data

Create StudentsRepository, PaymentsRepository, SettingsRepository. Do not create AppRepository containing everything. Each type of data has its own lifecycle, sources and rules.

Naming conventions (recommended)

Type Pattern Example
Data type Data type + Repository PaymentsRepository
Data source Type + source + DataSource PaymentsLocalDataSource, PaymentsRemoteDataSource
Models Source-specific suffixes PaymentEntity (database), PaymentDto or NetworkPayment (network), Payment (app model)
✏️ Your Task
Practise what you just learned about Conceptual Understanding: Repositories and Data Sources. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 13
Step-by-Step Build 1: Models for Each Layer

A single model class shared by network, database and UI looks tidy but couples everything. If the server renames amt to amount_paid, the screen would break. The recommended approach is separate models with mappers.

kotlin
// Network model: shaped like the server's JSON
@Serializable
data class PaymentDto(
 val id: Int,
 @SerialName("student_name") val studentName: String,
 @SerialName("amount_kobo") val amountKobo: Long,
 @SerialName("paid_at") val paidAt: String // ISO-8601
)
// Database model: shaped like the table
@Entity(tableName = "payments")
data class PaymentEntity(
 @PrimaryKey val id: Int,
 val studentName: String,
 val amountKobo: Long,
 val paidAtEpochMillis: Long
)
// App model: shaped like what the app needs
data class Payment(
 val id: Int,
 val studentName: String,
 val amountNaira: Long,
 val paidAt: Instant
)

Mapping functions sit next to the models (extension functions are idiomatic):

kotlin
fun PaymentDto.toEntity() = PaymentEntity(
 id = id,
 studentName = studentName,
 amountKobo = amountKobo,
 paidAtEpochMillis = Instant.parse(paidAt).toEpochMilli()
)
fun PaymentEntity.toModel() = Payment(
 id = id,
 studentName = studentName,
 amountNaira = amountKobo / 100,
 paidAt = Instant.ofEpochMilli(paidAtEpochMillis)
)

Notice the benefit: money is stored in kobo (a whole number, avoiding rounding errors) while the UI model uses naira. The server's date string becomes a real Instant. The ViewModel never sees PaymentDto.

When is one model enough? For tiny apps where the formats match, a single model is fine. Introduce separate models when they start to diverge. Do not create three identical classes "just in case".

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: Models for Each Layer. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 13
Step-by-Step Build 2: Data Sources

Local data source (Room)

kotlin
@Dao
interface PaymentDao {
 @Query("SELECT * FROM payments ORDER BY paidAtEpochMillis DESC")
 fun observeAll(): Flow<List<PaymentEntity>>
 @Upsert
 suspend fun upsertAll(payments: List<PaymentEntity>)
 @Query("DELETE FROM payments")
 suspend fun clear()
}

(@Upsert inserts or updates in one call; it exists in Room 2.5 and later and in Room 3. Check the current imports as in lesson 34.)

Remote data source (Retrofit)

kotlin
interface PaymentApi {
 @GET("payments")
 suspend fun getPayments(@Query("since") since: String? = null): List<PaymentDto>
 @POST("payments")
 suspend fun createPayment(@Body body: NewPaymentDto): PaymentDto
}

Retrofit's suspend functions run network work off the main thread by themselves, so a call can safely be made from viewModelScope with the default dispatcher. Even so, the repository still decides which dispatcher to use for CPU work such as mapping large lists.

Wrapping with DataSource classes

Wrapping the DAO and the API in small classes keeps the repository free of framework details and easy to fake:

kotlin
class PaymentsLocalDataSource(private val dao: PaymentDao) {
 fun observeAll(): Flow<List<PaymentEntity>> = dao.observeAll()
 suspend fun saveAll(items: List<PaymentEntity>) = dao.upsertAll(items)
}
class PaymentsRemoteDataSource(private val api: PaymentApi) {
 suspend fun fetchAll(): List<PaymentDto> = api.getPayments()
 suspend fun create(payment: NewPaymentDto): PaymentDto = api.createPayment(payment)
}

Some teams skip the wrapper and let the repository use the DAO or API directly, because they are already interfaces. Both are acceptable; use the wrapper when it adds a real seam for testing or hides details.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Data Sources. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 13
Step-by-Step Build 3: The Repository

The contract

kotlin
interface PaymentsRepository {
 /** Observes stored payments. Emits again whenever data changes. */
 fun observePayments(): Flow<List<Payment>>
 /** Fetches the latest payments from the server and stores them. */
 suspend fun refresh(): Result<Unit>
 /** Records a payment. */
 suspend fun record(studentName: String, amountNaira: Long): Result<Payment>
}

Principles in this small interface:

  • Reads are Flow: the UI gets updates automatically.
  • One-shot actions are suspend: callable only from a coroutine, off the main thread by the repository's own choice.
  • Results are explicit: failures are values (Result), not surprises.
  • Nothing mutable leaks: lists are read-only; models are immutable.

The implementation

kotlin
class DefaultPaymentsRepository(
 private val local: PaymentsLocalDataSource,
 private val remote: PaymentsRemoteDataSource,
 private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : PaymentsRepository {
 override fun observePayments(): Flow<List<Payment>> =
 local.observeAll()
 .map { entities -> entities.map { it.toModel() } }
 .flowOn(ioDispatcher)
 override suspend fun refresh(): Result<Unit> = withContext(ioDispatcher) {
 runCatchingNetwork {
 val dtos = remote.fetchAll()
 local.saveAll(dtos.map { it.toEntity() })
 }
 }
 override suspend fun record(studentName: String, amountNaira: Long): Result<Payment> =
 withContext(ioDispatcher) {
 runCatchingNetwork {
 val dto = remote.create(NewPaymentDto(studentName, amountNaira * 100))
 val entity = dto.toEntity()
 local.saveAll(listOf(entity))
 entity.toModel()
 }
 }
}

Key decisions here:

  • The UI observes the database, which is the single source of truth. refresh() writes new data into the database; observers update automatically (lesson 64 develops this).
  • ioDispatcher is injected with a default. Tests pass a test dispatcher.
  • flowOn(ioDispatcher) affects the upstream of the flow (the DAO query and the mapping), not the collector.
  • The repository maps PaymentEntity to Payment: no database type leaves the data layer.
✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: The Repository. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 13
Step-by-Step Build 4: Errors

Network and disk failures are normal events, not exceptional ones. Decide how errors travel.

A careful runCatching

Kotlin's runCatching catches every Throwable, including CancellationException. Catching that breaks coroutine cancellation. Write a helper that rethrows it:

kotlin
suspend inline fun <T> runCatchingNetwork(block: () -> T): Result<T> =
 try {
 Result.success(block())
 } catch (e: CancellationException) {
 throw e // never swallow cancellation
 } catch (e: IOException) {
 Result.failure(e) // no connection, timeouts
 } catch (e: HttpException) {
 Result.failure(e) // server answered with an error code
 }

(HttpException comes from Retrofit; IOException from java.io.)

A domain-flavoured error type

When the UI should react differently (retry, sign in again, show a message), a sealed type is clearer than a raw exception:

kotlin
sealed interface DataError {
 data object NoConnection : DataError
 data object Unauthorised : DataError
 data class Server(val code: Int) : DataError
 data class Unknown(val cause: Throwable) : DataError
}
class DataException(val error: DataError) : Exception()
fun Throwable.toDataError(): DataError = when (this) {
 is IOException -> DataError.NoConnection
 is HttpException -> if (code() == 401) DataError.Unauthorised else DataError.Server(code())
 else -> DataError.Unknown(this)
}

The ViewModel maps DataError to user-friendly state ("Check your connection"). The repository does not produce UI strings; it produces data and errors, and the UI layer decides the wording and language.

What about errors in flows?

An exception thrown inside a flow terminates it. The ViewModel adds .catch { ... } (lesson 61). Inside repositories, use catch only when you can recover (for example, fall back to cached data); otherwise let it flow downstream.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: Errors. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 13
Step-by-Step Build 5: Caching and Multiple Sources

In-memory cache with a mutex

For data that is read often and changes rarely (such as school settings), keep a small in-memory cache with a mutex to protect it from concurrent access:

kotlin
class SettingsRepository(
 private val remote: SettingsRemoteDataSource,
 private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
 private val mutex = Mutex()
 private var cached: Settings? = null
 suspend fun settings(forceRefresh: Boolean = false): Settings {
 if (!forceRefresh) cached?.let { return it } // fast path, no lock
 return mutex.withLock {
 // check again: another coroutine may have loaded while we waited
 if (!forceRefresh) cached?.let { return it }
 withContext(ioDispatcher) { remote.fetch() }.also { cached = it }
 }
 }
}

Points to notice: the cache is private, the lock covers only the update, and the second check avoids loading twice.

Combining sources: the policy decides

A repository may read from several sources. State your policy clearly:

Policy Behaviour Good for
Network-first Try the network, fall back to disk Data that must be fresh
Cache-first Show disk instantly, refresh in the background Feeds, lists
Offline-first (database as truth) UI reads only the database; sync updates it Apps used with poor connectivity

Offline-first is the recommended default for many apps and is the subject of lesson 64.

Writing: where do changes go?

Strategy Meaning Trade-off
Online-only Send to the server; update the database on success Simple; fails offline
Queued Save locally, mark as pending, upload later (WorkManager) Works offline; needs sync logic
Lazy Save locally, sync eventually Fast; conflict handling needed
✏️ Your Task
Practise what you just learned about Step-by-Step Build 5: Caching and Multiple Sources. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 13
Step-by-Step Build 6: Testing with Fakes

The guidance is to prefer fakes over mocks: a fake is a small working implementation, so tests describe behaviour instead of recorded calls.

A fake can only replace a class if callers depend on an abstraction. So make the data sources interfaces (the Room and Retrofit versions from Build 2 become implementations such as RoomPaymentsLocalDataSource), and fakes become trivial:

kotlin
interface PaymentsLocalDataSource {
 fun observeAll(): Flow<List<PaymentEntity>>
 suspend fun saveAll(items: List<PaymentEntity>)
}
class FakeLocal : PaymentsLocalDataSource {
 private val state = MutableStateFlow<List<PaymentEntity>>(emptyList())
 override fun observeAll() = state
 override suspend fun saveAll(items: List<PaymentEntity>) {
 state.value = (state.value.associateBy { it.id } + items.associateBy { it.id }).values.toList()
 }
}
class FakeRemote(var result: () -> List<PaymentDto>) : PaymentsRemoteDataSource {
 override suspend fun fetchAll(): List<PaymentDto> = result()
 override suspend fun create(payment: NewPaymentDto): PaymentDto = error("not needed")
}

(PaymentsRemoteDataSource is likewise turned into an interface with the production class RetrofitPaymentsRemoteDataSource.) Now the tests:

kotlin
class DefaultPaymentsRepositoryTest {
 private val dto = PaymentDto(1, "Ada", 150_000_00, "2026-10-01T09:00:00Z")
 @Test
 fun refresh_stores_remote_payments_and_observers_see_them() = runTest {
 val local = FakeLocal()
 val repo = DefaultPaymentsRepository(local, FakeRemote { listOf(dto) }, StandardTestDispatcher(testScheduler))
 val result = repo.refresh()
 assertTrue(result.isSuccess)
 assertEquals(listOf("Ada"), repo.observePayments().first().map { it.studentName })
 }
 @Test
 fun refresh_failure_returns_failure_and_keeps_old_data() = runTest {
 val local = FakeLocal().apply { saveAll(listOf(dto.toEntity())) }
 val repo = DefaultPaymentsRepository(local, FakeRemote { throw IOException("offline") }, StandardTestDispatcher(testScheduler))
 val result = repo.refresh()
 assertTrue(result.isFailure)
 assertEquals(1, repo.observePayments().first().size) // old data survives
 }
}

The second test captures an important rule of offline apps: a failed refresh must not erase what the user already has.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 6: Testing with Fakes. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 13
Step-by-Step Build 7: Threading and Ownership Rules

Two questions decide whether a data layer is pleasant or painful to use: who chooses the thread, and who owns the work.

Who chooses the thread?

The recommendation is that suspend functions in the data layer are main-safe. That means a caller on the main thread can call repository.refresh() without freezing the screen, because the repository switches to the right dispatcher internally. The ViewModel then needs no withContext calls and no knowledge of how slow the operation is. Room and Retrofit already move their own blocking work off the main thread, but your own mapping of thousands of rows, file reads or image processing is not covered by that promise, so wrap such work in withContext(ioDispatcher) or defaultDispatcher yourself. Use the IO dispatcher for waiting on disks and sockets, and the Default dispatcher for heavy computation such as sorting, filtering or parsing a very large list.

Who owns the work?

Most work started by a repository should last only as long as the caller is interested. If the ViewModel is cleared, a running refresh() is cancelled with it. That is correct for reads. Some work must outlive the screen, such as uploading a payment the user has just recorded: if they press back, the upload should still finish. For that case the repository needs a longer-lived scope, which you inject as an application-level CoroutineScope (a SupervisorJob plus a dispatcher), or you hand the job to WorkManager, which also survives process death and retries with back-off. Avoid GlobalScope, because nothing owns it and you cannot cancel or test it.

kotlin
class DefaultPaymentsRepository(
 private val local: PaymentsLocalDataSource,
 private val remote: PaymentsRemoteDataSource,
 private val ioDispatcher: CoroutineDispatcher,
 private val appScope: CoroutineScope // lives as long as the application
) : PaymentsRepository {
 suspend fun recordAndKeepUploading(studentName: String, amountNaira: Long) {
 // saved locally at once; the upload continues even if the caller is cancelled
 appScope.launch(ioDispatcher) { upload(studentName, amountNaira) }
 }
 private suspend fun upload(studentName: String, amountNaira: Long) { /* ... */ }
}

In lesson 64 you will replace this ad hoc scope with a WorkManager worker so that unsent payments survive a restart.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 7: Threading and Ownership Rules. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 13
Guided Practice Exercises
🎯 Your Challenge

Exercise 1: Leaky repository

Find three problems in this interface and fix them.

kotlin
interface StudentsRepository {
 fun getStudents(): MutableList<StudentEntity>
 fun loadFromServer(callback: (List<StudentDto>) -> Unit)
 fun dao(): StudentDao
}

(1) It returns a mutable list, so callers could change shared data. (2) It exposes database and network models (StudentEntity, StudentDto) instead of app models. (3) It exposes the DAO, breaking the abstraction, and uses a callback rather than coroutines. Fix:

kotlin
interface StudentsRepository {
 fun observeStudents(): Flow<List<Student>>
 suspend fun refresh(): Result<Unit>
}
🎯 Your Challenge

Exercise 2: Cancellation

Why is this helper dangerous, and what is the fix?

kotlin
suspend fun <T> safeCall(block: suspend () -> T): Result<T> =
 try { Result.success(block()) } catch (e: Exception) { Result.failure(e) }

CancellationException is an Exception. When the coroutine is cancelled (for example the user leaves the screen), the exception is caught and turned into a failure, so the coroutine may carry on instead of stopping. Rethrow it first:

kotlin
suspend fun <T> safeCall(block: suspend () -> T): Result<T> =
 try { Result.success(block()) }
 catch (e: CancellationException) { throw e }
 catch (e: Exception) { Result.failure(e) }
🎯 Your Challenge

Exercise 3: Policy choice

For each data type choose network-first, cache-first or offline-first: (a) the bursar's list of payments in an area with unreliable networks; (b) a live exchange rate; (c) the school's timetable that changes once a term.

(a) Offline-first: the database is the truth and sync happens when possible. (b) Network-first: stale values are misleading, with the last known value as a labelled fallback. (c) Cache-first: show stored data immediately and refresh occasionally in the background.

✏️ Your Task
Practise what you just learned about Guided Practice Exercises. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 13
Common Beginner Mistakes (and How to Fix Them)

Mistake #1: Exposing DAOs, DTOs or entities to the UI. Fix: map to app models in the repository.

Mistake #2: Returning mutable collections. Fix: return List, copy on change.

Mistake #3: Swallowing CancellationException. Fix: rethrow it.

Mistake #4: One giant repository. Fix: one per type of data.

Mistake #5: Hard-coded dispatchers. Fix: inject them with sensible defaults.

Mistake #6: Clearing local data before the refresh succeeds. Fix: write only after success; use transactions when replacing.

Mistake #7: Putting UI text in repositories. Fix: return typed errors; the UI picks wording.

Mistake #8: Money as Double. Fix: use whole units (kobo) or BigDecimal.

Mistake #9: Mocking every call in tests. Fix: write simple fakes.

Mistake #10: Using GlobalScope for sync. Fix: use a scope the app owns, or WorkManager.

✏️ Your Task
Practise what you just learned about Common Beginner Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 13 of 13
Knowledge Check

Question 1. What are the main responsibilities of a repository?

Expose data, centralise changes, resolve conflicts between sources, abstract the sources, and hold business logic about the data.

Question 2. Why use separate network, database and app models?

So that a change in one format (a renamed JSON field, a new column) does not ripple through the whole app; mappers absorb the change.

Question 3. What does flowOn(ioDispatcher) change?

It changes the context of the upstream operators (the flow's producer and earlier operators), not the collector.

Question 4. Why prefer fakes over mocks?

Fakes behave like real implementations, so tests check results and stay valid when internal calls change; mocks tie tests to specific calls.

Question 5. What must happen if a refresh fails while offline?

The existing local data must remain, and the failure is reported as a value so the UI can show a message.

Go deeper: official resources

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
A Tested Payments Repository

Project goal: Build and test the data layer for the FeeTracker payments feature.

Requirements:

  1. Create PaymentDto, PaymentEntity and Payment, with mappers in both directions where needed.
  2. Define PaymentsLocalDataSource and PaymentsRemoteDataSource as interfaces, with Room and Retrofit implementations (or a fake remote with delay if you have no server).
  3. Implement DefaultPaymentsRepository with observePayments(), refresh() and record() exposing Flow and Result.
  4. Use the careful runCatchingNetwork helper and a DataError mapping.
  5. Inject the dispatcher.
  6. Add fakes and write at least five tests: successful refresh, failed refresh keeps old data, record success updates observers, cancellation is not swallowed, and mapping converts kobo to naira.
  7. Document the repository's sync policy in a comment at the top of the class.

Stretch goal: add an in-memory cache for settings with a mutex and test that two concurrent calls hit the remote only once.

A cancellation test and a mapping test:

kotlin
class RunCatchingNetworkTest {
 @Test
 fun cancellation_is_rethrown() = runTest {
 val job = launch {
 runCatchingNetwork { throw CancellationException("stop") }
 fail("should not continue")
 }
 job.join()
 assertTrue(job.isCancelled)
 }
}
class MappersTest {
 @Test
 fun entity_converts_kobo_to_naira() {
 val entity = PaymentEntity(1, "Ada", amountKobo = 150_000_00, paidAtEpochMillis = 0)
 assertEquals(150_000L, entity.toModel().amountNaira)
 }
}

The concurrent-cache stretch test:

kotlin
@Test
fun concurrent_calls_load_once() = runTest {
 var calls = 0
 val remote = object : SettingsRemoteDataSource {
 override suspend fun fetch(): Settings { calls++; delay(100); return Settings("ISCGS") }
 }
 val repo = SettingsRepository(remote, StandardTestDispatcher(testScheduler))
 val a = async { repo.settings() }
 val b = async { repo.settings() }
 awaitAll(a, b)
 assertEquals(1, calls)
}

A top-of-class policy comment (example):

text
Policy: offline-first. The UI observes the database only. refresh() downloads
payments and upserts them; a failed refresh never deletes local data.
record() posts to the server and, on success, stores the returned payment.
1

Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.

2

Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 62 project”.

3

On github.com click New repository, name it android-lesson-62-the-data-layer-repositories-data-sources-a, set it to Public, leave Add a README unticked, and click Create repository.

4

Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.

5

Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉

Lesson 62 complete! 🎉

You covered:

  • Lesson Introduction
  • Prerequisite Concepts
  • Conceptual Understanding: Repositories and Data Sources
  • Step-by-Step Build 1: Models for Each Layer
  • Step-by-Step Build 2: Data Sources
  • Step-by-Step Build 3: The Repository
  • Step-by-Step Build 4: Errors
  • Step-by-Step Build 5: Caching and Multiple Sources
  • Step-by-Step Build 6: Testing with Fakes
  • Step-by-Step Build 7: Threading and Ownership Rules
  • Guided Practice Exercises
  • Common Beginner Mistakes (and How to Fix Them)
  • Knowledge Check
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close