Android System Design Interview Questions
Design a Google Notes app.
Tier: CommonDifficulty: Hard
Design a notes app where a user can create, read, edit and delete text notes, including while offline. When the connection returns, changes should sync to that user's other devices.
The problem
Start with a notes list and an editor. A note reported as saved should survive the app closing. Edits made without a network must remain available and sync later. Decide what happens if two devices change the same note before either sees the other's edit.
For example, Ana edits her shopping list on an offline phone and closes the app. Reopening it must show her saved edit. After reconnection, her tablet should receive it. If the tablet also edited that note, the app needs an explicit merge or conflict outcome rather than silently losing one version.
Focus on text notes, local storage and synchronization. Attachments, reminders and several people typing together are separate extensions. The walkthrough describes an interview design, not Google's internal implementation.
Starting the design
I'd build a notes app with MVVM. Compose displays state from the ViewModel, and user actions go back through the ViewModel to a repository. The repository saves notes in Room, which is the only source the UI reads from. A save writes the note and an upload operation together before making any network request. That lets the user keep writing offline, while the app syncs changes later.
This is how I'd approach the interview, rather than a description of Google's internal code. The Android examples show how the layers connect. Imports and routine Gradle setup are omitted, so this is not a complete Android project. The code browser shows the merge algorithm separately in Java and Kotlin.
Scope and use cases
I'd start with text notes, a list and an editor. Creating, editing and deleting should work offline, with sync across one user's devices. I'd ask whether the round also needs labels, attachments, reminders or search. Several people typing in the same note at once is a larger problem, so I'd agree on that separately.
Before choosing libraries, I'd agree on what the design must guarantee.
- Once the app says Saved, the note survives a process restart.
- The list and editor work without internet.
- Retrying an upload does not create another note.
- An upload finishing does not erase an edit made while it was running.
I'd estimate note count and attachment size separately. Those numbers help decide when to use paging and how much storage to allow.
High level Android architecture
This follows Android's recommended UI and data layers. State goes down to the UI, and actions go back up. That is unidirectional data flow. A domain layer is optional. I'd add SaveNoteUseCase if several screens shared save rules, but a class that only forwards one repository call adds little here. See the architecture recommendations.
| Component | What it does |
|---|---|
| Route composable | Gets the ViewModel, collects state and handles navigation |
| Screen composable | Draws the state and sends actions through callbacks |
| ViewModel | Holds screen state, validates input and starts save calls |
| Repository | Reads local data and saves a note with its upload operation |
| DAO | Runs Room queries and writes database rows |
| Sync engine and worker | Uploads changes, downloads updates, handles conflicts and retries |
Saving a note with Room and a repository
I'd create the UUID on the phone so an offline note already has its final ID. The UI's Note, the Room row and the API request are separate types because each needs different fields. This example uses one database per signed in account. A shared database would need accountId in every key and query.
The outbox is a table of changes waiting to upload. Saving it with the note means the app cannot save the text and then forget that it needs to sync.
// data/local/NotesDatabase.kt
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey val id: String,
val title: String,
val body: String,
val deleted: Boolean = false,
val localVersion: Long = 0,
val serverRevision: Long = 0,
val editedAt: Long,
)
// Immutable payloads make a retry refer to the same logical operation.
@Entity(tableName = "note_outbox")
data class NoteMutation(
@PrimaryKey val mutationId: String,
val noteId: String,
val localVersion: Long,
val title: String,
val body: String,
val deleted: Boolean,
)
data class Note(val id: String, val title: String, val body: String)
@Dao
interface NoteDao {
@Query("SELECT * FROM notes WHERE deleted = 0 ORDER BY editedAt DESC, id")
fun observeNotes(): Flow<List<NoteEntity>>
@Query("SELECT * FROM notes WHERE id = :id")
suspend fun find(id: String): NoteEntity?
@Upsert suspend fun upsert(note: NoteEntity)
}
@Dao
interface OutboxDao {
@Insert suspend fun insert(mutation: NoteMutation)
@Query("SELECT * FROM note_outbox ORDER BY noteId, localVersion LIMIT :limit")
suspend fun pending(limit: Int): List<NoteMutation>
@Query("DELETE FROM note_outbox WHERE mutationId = :mutationId")
suspend fun acknowledge(mutationId: String)
}
@Database(entities = [NoteEntity::class, NoteMutation::class], version = 1)
abstract class NotesDatabase : RoomDatabase() {
abstract fun notes(): NoteDao
abstract fun outbox(): OutboxDao
}
These entities cover the local save. Full sync also needs the last synced copy of each note, saved conflict copies and a cursor that marks how far the app has downloaded changes. The last synced copy lets us tell what changed on each device. editedAt sorts the list, but device clocks do not decide which edit wins. For a large collection, I'd add indexes and return a DAO PagingSource. Room FTS provides full text search.
// data/NotesRepository.kt
interface NotesRepository {
fun observeNotes(): Flow<List<Note>>
suspend fun save(id: String, title: String, body: String, deleted: Boolean = false)
}
interface SyncScheduler {
fun enqueue()
}
class OfflineNotesRepository(
private val db: NotesDatabase,
private val scheduler: SyncScheduler,
private val now: () -> Long = System::currentTimeMillis,
) : NotesRepository {
override fun observeNotes(): Flow<List<Note>> =
db.notes().observeNotes().map { rows ->
rows.map { Note(it.id, it.title, it.body) }
}.distinctUntilChanged()
override suspend fun save(id: String, title: String, body: String, deleted: Boolean) {
db.withTransaction {
val old = db.notes().find(id)
val nextVersion = (old?.localVersion ?: 0) + 1
db.notes().upsert(NoteEntity(
id = id, title = title, body = body, deleted = deleted,
localVersion = nextVersion,
serverRevision = old?.serverRevision ?: 0,
editedAt = now(),
))
db.outbox().insert(NoteMutation(
mutationId = UUID.randomUUID().toString(),
noteId = id, localVersion = nextVersion,
title = title, body = body, deleted = deleted,
))
}
scheduler.enqueue()
}
}
Both writes succeed or neither does. After the transaction, the DAO's Flow emits the updated list. The UI does not wait for an upload. Room's suspend APIs handle their database work off the main thread. Other blocking file work belongs on an injected I/O dispatcher, and expensive parsing or merging belongs on a dispatcher for CPU work. I'd avoid allowMainThreadQueries(). See Room asynchronous queries.
The app can still die after saving in Room but before scheduling WorkManager. I'd check for pending work at signed in startup and periodically schedule another sync. Saving to Room and scheduling a worker are separate operations. If scheduling fails, the note is still saved locally. The UI should say sync is delayed, not that the save was lost.
Where the network call happens
// data/remote/NotesApi.kt
interface NotesApi {
@POST("notes/mutations")
suspend fun push(
@Header("Idempotency-Key") mutationId: String,
@Body request: PushNote,
): RemoteNote
@GET("notes/changes")
suspend fun changes(@Query("cursor") cursor: String?): ChangesPage
}
data class PushNote(
val noteId: String,
val baseRevision: Long,
val title: String,
val body: String,
val deleted: Boolean,
)
data class RemoteNote(
val id: String, val revision: Long,
val title: String, val body: String, val deleted: Boolean,
)
data class ChangesPage(
val notes: List<RemoteNote>, val nextCursor: String, val hasMore: Boolean,
)
I'd configure Retrofit with an OkHttp client, a JSON converter, HTTPS and timeouts. The client gets authentication tokens from the current session. The ViewModel does not manage them. The server recognises a repeated mutation ID within the account, so retrying the same request does not apply it twice. It also checks the expected note version and reports a conflict if that version is old. A deletion is sent as a tombstone, a saved marker saying the note was deleted.
The call path is straightforward. The ViewModel calls repository.save, which saves locally and schedules work. The worker calls the sync engine. The engine makes the network requests through api.push and api.changes.
I'd walk through the engine in this order. No Room transaction stays open while waiting for HTTP.
- Run one sync at a time for the account and take the next saved operation. Keep changes to each note in order. Before sending, save the exact request, including the version of the last acknowledged note. A retry sends that same request and ID.
- Upload the note. In a short Room transaction, save the server version and the accepted copy. Remove only this operation from the outbox. A newer local edit and its later operation must survive.
- If the server reports a conflict, fetch its note and compare the last synced copy, the local copy and the server copy. Keep conflicting text in a separate saved copy. A merged result gets a new operation ID and uses the new server version.
- Download one page of changes and merge it with pending local edits. Save the changed notes, their last synced copies and the next cursor together. If the app crashes, it cannot claim to have downloaded changes it never saved.
- Stop after a limited amount of work and schedule another run if needed. If the server resets a cursor, download the data again in pages while preserving pending edits.
// sync/NotesSyncWorker.kt
// This boundary is implemented by the protocol above and the merge below.
interface NotesSyncEngine {
suspend fun runBatch(): SyncBatch
}
enum class SyncBatch { Complete, MorePending, NeedsSignIn, Conflict }
class NotesSyncWorker(
context: Context,
params: WorkerParameters,
private val engine: NotesSyncEngine,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = try {
when (engine.runBatch()) {
SyncBatch.Complete -> Result.success()
SyncBatch.MorePending -> Result.retry()
SyncBatch.NeedsSignIn, SyncBatch.Conflict -> Result.failure()
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (offline: IOException) {
Result.retry()
} catch (http: HttpException) {
if (http.code() == 429 || http.code() in 500..599) Result.retry()
else Result.failure()
}
}
class WorkSyncScheduler(private val workManager: WorkManager) : SyncScheduler {
override fun enqueue() {
val work = OneTimeWorkRequestBuilder<NotesSyncWorker>()
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
workManager.enqueueUniqueWork(
"notes-sync", ExistingWorkPolicy.APPEND_OR_REPLACE, work,
)
}
}
Before stopping for a conflict or sign in, the engine saves that state so the UI can explain it. The pending change stays in the outbox. A later user action can resume it. Temporary failures follow server retry delays. Invalid data needs a fix instead of endless retries.
APPEND_OR_REPLACE schedules another run when a save arrives as the current worker is finishing. A larger app should combine bursts of scheduling requests so every keystroke does not add another worker. WorkManager remembers scheduled work, but it does not promise to run immediately. See managing unique work.
How the ViewModel and Compose receive updates
// ui/NotesViewModel.kt
sealed interface NotesUiState {
data object Loading : NotesUiState
data class Ready(val notes: List<Note>) : NotesUiState
data object StorageError : NotesUiState
}
class NotesViewModel(private val repository: NotesRepository) : ViewModel() {
val uiState: StateFlow<NotesUiState> = repository.observeNotes()
.map<List<Note>, NotesUiState> { NotesUiState.Ready(it) }
.catch { cause ->
if (cause is CancellationException) throw cause
emit(NotesUiState.StorageError)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), NotesUiState.Loading)
private val _saveError = MutableStateFlow(false)
val saveError = _saveError.asStateFlow()
private val _saving = MutableStateFlow(false)
val saving = _saving.asStateFlow()
fun save(id: String, title: String, body: String) {
if (_saving.value) return
_saving.value = true
_saveError.value = false
viewModelScope.launch {
try {
repository.save(id, title, body)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (storage: SQLiteException) {
_saveError.value = true
} finally {
_saving.value = false
}
}
}
}
stateIn turns the Room flow into shared screen state. The five second timeout keeps the subscription alive through a brief rotation. It does not save the job across process death, or cancel separate viewModelScope.launch jobs when the screen stops.
A ViewModel survives rotation, but Android can still kill its process. I'd put IDs and small editor state in SavedStateHandle, and save larger drafts in Room. If the editor uses autosave, I'd wait briefly after typing, write changes in order and show when the save finishes. onCleared() is not a reliable place to save the last keystroke.
// ui/NotesRoute.kt
@Composable
fun NotesRoute(viewModel: NotesViewModel, onOpen: (String) -> Unit) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
when (val current = state) {
NotesUiState.Loading -> CircularProgressIndicator()
NotesUiState.StorageError -> Text("Could not read saved notes")
is NotesUiState.Ready -> NotesList(current.notes, onOpen)
}
}
@Composable
fun NotesList(notes: List<Note>, onOpen: (String) -> Unit) {
if (notes.isEmpty()) {
Text("Create your first note")
return
}
LazyColumn(state = rememberLazyListState()) {
items(notes, key = { it.id }, contentType = { "note" }) { note ->
ListItem(
headlineContent = { Text(note.title, maxLines = 1) },
supportingContent = { Text(note.body, maxLines = 2) },
modifier = Modifier.clickable { onOpen(note.id) },
)
}
}
}
// In an editor route, draftId is generated once and restored across recreation.
@Composable
fun SaveNoteButton(vm: NotesViewModel, draftId: String, title: String, body: String) {
val saving by vm.saving.collectAsStateWithLifecycle()
val failed by vm.saveError.collectAsStateWithLifecycle()
Button(enabled = !saving, onClick = { vm.save(draftId, title, body) }) {
Text(if (saving) "Saving" else "Save")
}
if (failed) Text("Could not save on this device. Try again.")
}
NotesRoute subscribes with collectAsStateWithLifecycle(). When Room changes, the ViewModel publishes a new state and Compose redraws it. Tapping Save calls the ViewModel through a callback. Recomposition itself never starts a save or request.
If a screen needs an initial refresh, I'd start it once in the ViewModel or in a LaunchedEffect with a deliberate key. Using both can send the request twice. In this design, signed in startup starts sync and later saves schedule more work.
For the list, I'd call out a few Compose details.
- Use the note ID as the key so remembered row state follows the note when the order changes.
- Use
contentTypefor the row layout. It helps Compose reuse similar rows, but does not identify a note. - Keep business state above the row and send actions through callbacks.
- Limit text previews and thumbnail sizes. Sort or parse before drawing items.
- Use Paging for a large dataset.
LazyColumnonly draws needed rows, but a large backingListstill occupies memory.
See Compose lists and side effects.
Dependency injection from simple to Hilt
Dependency injection means giving a class the objects it needs, usually through its constructor. I'd create Room, Retrofit and the repository once in the app setup, then pass them to their users. A composable should not build a new database or client every time it runs.
// di/NotesGraph.kt. Supply the sync engine implementing the protocol above.
class NotesGraph(
context: Context,
val api: NotesApi,
createEngine: (NotesDatabase, NotesApi) -> NotesSyncEngine,
) {
private val appContext = context.applicationContext
private val scheduler = object : SyncScheduler {
override fun enqueue() {
WorkSyncScheduler(WorkManager.getInstance(appContext)).enqueue()
}
}
val database = Room.databaseBuilder(
context.applicationContext, NotesDatabase::class.java, "notes.db",
).build()
val repository: NotesRepository = OfflineNotesRepository(database, scheduler)
val syncEngine = createEngine(database, api)
val workerFactory = NotesWorkerFactory(syncEngine)
val viewModelFactory = viewModelFactory {
initializer { NotesViewModel(repository) }
}
}
@Composable
fun NotesDestination(graph: NotesGraph, onOpen: (String) -> Unit) {
val vm: NotesViewModel = viewModel(factory = graph.viewModelFactory)
NotesRoute(vm, onOpen)
}
// Register this factory in WorkManager's Configuration.Provider.
class NotesWorkerFactory(private val engine: NotesSyncEngine) : WorkerFactory() {
override fun createWorker(
appContext: Context, workerClassName: String, workerParameters: WorkerParameters,
): ListenableWorker? = when (workerClassName) {
NotesSyncWorker::class.java.name ->
NotesSyncWorker(appContext, workerParameters, engine)
else -> null
}
}
The application creates this graph once for the signed in account. The sync engine and repository share its database. WorkManager is looked up only when scheduling, so building the graph does not ask WorkManager for a factory that has not been created yet. On logout, cancel the account's work and close or clear its local data before switching accounts.
The custom worker factory supplies NotesSyncEngine, which Android's default constructor cannot provide. Register the factory through WorkManager configuration and disable the default initializer. See custom WorkManager initialization.
Hilt is Android's recommended dependency injection library. It generates much of this setup. The main pieces are these.
@HiltAndroidAppstarts Hilt from the application.@AndroidEntryPointlets the Activity receive dependencies.@HiltViewModeland constructor@Injecttell Hilt how to create the ViewModel. The earlier state and action methods stay the same.- A
@Modulewith@InstallIn(SingletonComponent::class)supplies shared objects such as the database and API.
The provider below can build the existing repository. If an app singleton outlives a login, it needs a session object that handles account changes safely.
@Module
@InstallIn(SingletonComponent::class)
object NotesModule {
@Provides @Singleton
fun database(@ApplicationContext context: Context): NotesDatabase =
Room.databaseBuilder(context, NotesDatabase::class.java, "notes.db").build()
@Provides @Singleton
fun scheduler(@ApplicationContext context: Context): SyncScheduler =
object : SyncScheduler {
override fun enqueue() {
WorkSyncScheduler(WorkManager.getInstance(context)).enqueue()
}
}
@Provides @Singleton
fun repository(db: NotesDatabase, scheduler: SyncScheduler): NotesRepository =
OfflineNotesRepository(db, scheduler, now = System::currentTimeMillis)
}
// Alternative constructor for the earlier ViewModel, with the same body.
// @HiltViewModel
// class NotesViewModel @Inject constructor(repository: NotesRepository) : ViewModel()
// At the destination, import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel.
@Composable
fun HiltNotesDestination(onOpen: (String) -> Unit) {
val vm: NotesViewModel = hiltViewModel()
NotesRoute(vm, onOpen)
}
@Binds is another option when the implementation has an injectable constructor and Hilt knows how to provide every argument. Kotlin default arguments do not create those bindings. The sync setup also needs providers for NotesApi and NotesSyncEngine.
For a Hilt worker, use @HiltWorker and an @AssistedInject constructor. Android supplies the assisted Context and WorkerParameters, and Hilt supplies the other dependencies. The application gives HiltWorkerFactory to WorkManager through its configuration. A destination scoped ViewModel belongs to its navigation back stack entry. It stays with that entry until the entry is removed. See manual DI, Hilt setup and Hilt workers.
This is where the API object comes from. The app supplies its HTTPS base URL and an OkHttp client that reads the current session token. A real token never belongs directly in the module.
fun notesApi(baseUrl: String, client: OkHttpClient): NotesApi =
Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(NotesApi::class.java)
To turn the examples into an Android module, add the dependencies for each part.
- Room runtime and its compiler through KSP for the database.
- WorkManager for saved background work, and Retrofit with a JSON converter for requests.
- Lifecycle ViewModel,
lifecycle-runtime-composeand coroutines for state and collection. - Compose UI, foundation and Material for the screen.
- For Hilt, its Gradle plugin and compiler,
hilt-lifecycle-viewmodel-composeand the WorkManager integration.
Add INTERNET to the manifest. The manual version uses the factory without Hilt. Keep versions compatible through the project's version catalog and Compose BOM.
Conflict resolution and the core implementation
I'd compare three copies of the note, the last synced version, the local edit and the server edit. That is a three way merge. If only one device changed the title, keep its title. If one changed the title and the other changed the body, keep both changes. If both changed the body differently, save a conflict copy so the user can recover either version. Simply keeping the latest write is easier, but loses someone's edit.
If several people need to type together, I'd discuss CRDT or OT algorithms, which combine editing operations across devices. That extra complexity is easier to justify for live collaboration.
The Java and Kotlin examples below show the field comparison. They choose the server value when both sides change the same field, and deletion wins over an edit. Before using that policy for user notes, I'd add saved conflict copies so text can be recovered. The examples use memory storage to demonstrate the algorithm. They do not implement the Room sync flow above.
Java
com.androidinterview.notes.data.NoteStore.java
package com.androidinterview.notes.data;
import com.androidinterview.notes.model.Note;
import java.util.List;
// Room sits behind this in a real app, two tables, the notes the user edits
// and the last synced copy of each. The dirty flag lives on the Room row, not
// on the domain model. The sync engine only ever sees this interface, so it
// runs in a plain unit test with no device attached.
public interface NoteStore {
Note local(String id); // what the screen shows and the user edits, null if never seen here
Note base(String id); // the last copy pulled from the server, null if never pulled
List<Note> dirty(); // edited since the last successful push
// Merged row and its new base, written in one transaction. Two writes
// would leave a crash between them looking like an unsynced edit forever.
void apply(Note merged);
long cursor();
void cursor(long value);
}
package com.androidinterview.notes.data;
import com.androidinterview.notes.model.Note;
import java.util.List;
public interface NoteStore {
Note local(String id);
Note base(String id);
List<Note> dirty();
void apply(Note merged);
long cursor();
void cursor(long value);
}
com.androidinterview.notes.model.Note.java
package com.androidinterview.notes.model;
// One row of the notes table. The ordering field is revision, which the server
// owns, never a device clock. A phone with the wrong date would otherwise win
// every conflict it took part in.
//
// A delete is a tombstone rather than a missing row. Drop the row and the next
// pull from another device happily resurrects the note.
public record Note(
String id,
String title,
String body,
long revision,
long deletedAt) {
public boolean deleted() {
return deletedAt > 0;
}
}
package com.androidinterview.notes.model;
public record Note(
String id,
String title,
String body,
long revision,
long deletedAt) {
public boolean deleted() {
return deletedAt > 0;
}
}
com.androidinterview.notes.sync.FieldMerge.java
package com.androidinterview.notes.sync;
import com.androidinterview.notes.model.Note;
// The conflict rule, and the only genuinely hard decision in a notes app.
//
// Last write wins on the whole note is one line and silently throws away
// somebody's paragraph. An operation log merges properly and is a lot of
// machinery. Field level merge sits in between, and it wins the common case,
// two devices touching different parts of the same note.
public final class FieldMerge {
private FieldMerge() {
}
// Three inputs, not two. base is the last copy this device pulled from the
// server, local is what the user has edited since, remote is what the
// server holds now. Without base there is no way to tell an edited field
// from an untouched one, which is why a two way merge cannot do this.
public static Note merge(Note base, Note local, Note remote) {
// A note this device has never seen is just the remote copy. This is
// every row of the first sync on a new device.
if (base == null || local == null) return remote;
if (local.deleted() || remote.deleted()) {
// A delete beats an edit. Arguable, and worth saying out loud that
// it is a product call rather than a technical one.
long at = Math.max(local.deletedAt(), remote.deletedAt());
return new Note(remote.id(), remote.title(), remote.body(), remote.revision(), at);
}
return new Note(
remote.id(),
pick(base.title(), local.title(), remote.title()),
pick(base.body(), local.body(), remote.body()),
remote.revision(),
0);
}
// Four lines carry the whole policy. Only the last one loses an edit, and
// naming that case honestly is most of the answer.
private static String pick(String base, String local, String remote) {
if (local.equals(remote)) return local; // nobody disagrees
if (local.equals(base)) return remote; // only the server moved
if (remote.equals(base)) return local; // only this device moved
return remote; // both moved, the server wins, the local edit is lost
}
}
package com.androidinterview.notes.sync;
import com.androidinterview.notes.model.Note;
public final class FieldMerge {
private FieldMerge() {
}
public static Note merge(Note base, Note local, Note remote) {
if (base == null || local == null) return remote;
if (local.deleted() || remote.deleted()) {
long at = Math.max(local.deletedAt(), remote.deletedAt());
return new Note(remote.id(), remote.title(), remote.body(), remote.revision(), at);
}
return new Note(
remote.id(),
pick(base.title(), local.title(), remote.title()),
pick(base.body(), local.body(), remote.body()),
remote.revision(),
0);
}
private static String pick(String base, String local, String remote) {
if (local.equals(remote)) return local;
if (local.equals(base)) return remote;
if (remote.equals(base)) return local;
return remote;
}
}
com.androidinterview.notes.sync.NoteSync.java
package com.androidinterview.notes.sync;
import com.androidinterview.notes.data.NoteStore;
import com.androidinterview.notes.model.Note;
import java.util.List;
// One pass of sync. WorkManager runs it with a network constraint in a real
// app, so it survives the process dying and resumes when connectivity returns.
// Nothing here knows that, which is the point of keeping the engine plain.
public final class NoteSync {
// Retrofit sits behind this. push hands the server the revision this edit
// was based on, so a stale base comes back as the server's current note
// rather than overwriting it.
public interface NoteApi {
Note push(Note note, long baseRevision);
Page since(long cursor);
record Page(List<Note> notes, long cursor, boolean hasMore) {
}
}
private final NoteStore store;
private final NoteApi api;
public NoteSync(NoteStore store, NoteApi api) {
this.store = store;
this.api = api;
}
public void syncOnce() {
push();
pull();
}
// Push first, so a note the user typed a second ago reaches the server
// before a pull can merge over it.
private void push() {
for (Note local : store.dirty()) {
Note base = store.base(local.id());
Note server = api.push(local, base == null ? 0 : base.revision());
// local is read again after the round trip. A keystroke typed while
// the push was in flight is then the local side of the merge rather
// than something the server's copy silently overwrites.
store.apply(FieldMerge.merge(base, store.local(local.id()), server));
}
}
// Then pull everything changed since the cursor, one page at a time. A new
// device asking for a hundred thousand notes in one response times out, so
// the cursor advances per page and a crash halfway costs one page, not the
// whole sync.
private void pull() {
NoteApi.Page page = api.since(store.cursor());
while (true) {
for (Note remote : page.notes()) {
store.apply(FieldMerge.merge(store.base(remote.id()), store.local(remote.id()), remote));
}
store.cursor(page.cursor());
if (!page.hasMore()) return;
page = api.since(page.cursor());
}
}
}
package com.androidinterview.notes.sync;
import com.androidinterview.notes.data.NoteStore;
import com.androidinterview.notes.model.Note;
import java.util.List;
public final class NoteSync {
public interface NoteApi {
Note push(Note note, long baseRevision);
Page since(long cursor);
record Page(List<Note> notes, long cursor, boolean hasMore) {
}
}
private final NoteStore store;
private final NoteApi api;
public NoteSync(NoteStore store, NoteApi api) {
this.store = store;
this.api = api;
}
public void syncOnce() {
push();
pull();
}
private void push() {
for (Note local : store.dirty()) {
Note base = store.base(local.id());
Note server = api.push(local, base == null ? 0 : base.revision());
store.apply(FieldMerge.merge(base, store.local(local.id()), server));
}
}
private void pull() {
NoteApi.Page page = api.since(store.cursor());
while (true) {
for (Note remote : page.notes()) {
store.apply(FieldMerge.merge(store.base(remote.id()), store.local(remote.id()), remote));
}
store.cursor(page.cursor());
if (!page.hasMore()) return;
page = api.since(page.cursor());
}
}
}
Kotlin
com.androidinterview.notes.data.NoteStore.kt
package com.androidinterview.notes.data
import com.androidinterview.notes.model.Note
// Room sits behind this in a real app, two tables, the notes the user edits
// and the last synced copy of each. The dirty flag lives on the Room row, not
// on the domain model. The engine sees only this interface, so it runs in a
// plain unit test with no device.
interface NoteStore {
fun local(id: String): Note? // what the screen shows, null if never seen here
fun base(id: String): Note? // the last copy pulled, null if never pulled
fun dirty(): List<Note>
// Merged row and its new base in one transaction. Two writes would leave a
// crash between them looking like an unsynced edit forever.
fun apply(merged: Note)
var cursor: Long
}
package com.androidinterview.notes.data
import com.androidinterview.notes.model.Note
interface NoteStore {
fun local(id: String): Note?
fun base(id: String): Note?
fun dirty(): List<Note>
fun apply(merged: Note)
var cursor: Long
}
com.androidinterview.notes.model.Note.kt
package com.androidinterview.notes.model
// One row of the notes table. The ordering field is revision, which the server
// owns, never a device clock. A phone with the wrong date would otherwise win
// every conflict it took part in.
//
// A delete is a tombstone rather than a missing row. Drop the row and the next
// pull from another device happily resurrects the note.
data class Note(
val id: String,
val title: String,
val body: String,
val revision: Long,
val deletedAt: Long = 0,
) {
val deleted: Boolean get() = deletedAt > 0
}
package com.androidinterview.notes.model
data class Note(
val id: String,
val title: String,
val body: String,
val revision: Long,
val deletedAt: Long = 0,
) {
val deleted: Boolean get() = deletedAt > 0
}
com.androidinterview.notes.sync.FieldMerge.kt
package com.androidinterview.notes.sync
import com.androidinterview.notes.model.Note
// Three inputs, not two. base is the last copy this device pulled, local is
// what the user edited since, remote is what the server holds now. Without
// base there is no way to tell an edited field from an untouched one, which is
// why a two way merge cannot do this at all.
fun merge(base: Note?, local: Note?, remote: Note): Note {
// A note this device has never seen is just the remote copy. This is every
// row of the first sync on a new device.
if (base == null || local == null) return remote
return when {
local.deleted || remote.deleted ->
// A delete beats an edit. Arguable, and worth saying out loud that
// it is a product call rather than a technical one.
remote.copy(deletedAt = maxOf(local.deletedAt, remote.deletedAt))
else -> remote.copy(
title = pick(base.title, local.title, remote.title),
body = pick(base.body, local.body, remote.body),
)
}
}
// The whole policy. Only the last branch loses an edit, and naming that case
// honestly is most of the answer. copy on a data class is what keeps the merge
// to the two fields that actually merge, rather than rebuilding the note.
private fun pick(base: String, local: String, remote: String): String = when {
local == remote -> local // nobody disagrees
local == base -> remote // only the server moved
remote == base -> local // only this device moved
else -> remote // both moved, the server wins, the local edit is lost
}
package com.androidinterview.notes.sync
import com.androidinterview.notes.model.Note
fun merge(base: Note?, local: Note?, remote: Note): Note {
if (base == null || local == null) return remote
return when {
local.deleted || remote.deleted ->
remote.copy(deletedAt = maxOf(local.deletedAt, remote.deletedAt))
else -> remote.copy(
title = pick(base.title, local.title, remote.title),
body = pick(base.body, local.body, remote.body),
)
}
}
private fun pick(base: String, local: String, remote: String): String = when {
local == remote -> local
local == base -> remote
remote == base -> local
else -> remote
}
com.androidinterview.notes.sync.NoteSync.kt
package com.androidinterview.notes.sync
import com.androidinterview.notes.data.NoteStore
import com.androidinterview.notes.model.Note
data class Page(val notes: List<Note>, val cursor: Long, val hasMore: Boolean)
// Retrofit sits behind this.
interface NoteApi {
// push carries the revision this edit was based on, so a stale base comes
// back as the server's current note instead of overwriting it.
suspend fun push(note: Note, baseRevision: Long): Note
suspend fun since(cursor: Long): Page
}
// WorkManager runs this with a network constraint in a real app, so it
// survives the process dying and resumes when connectivity returns. Nothing
// here knows that, which is the point of keeping the engine plain.
class NoteSync(private val store: NoteStore, private val api: NoteApi) {
suspend fun syncOnce() {
// Push first, so a note typed a second ago reaches the server before a
// pull can merge over it.
store.dirty().forEach { local ->
val base = store.base(local.id)
val server = api.push(local, base?.revision ?: 0)
// local is read again after the round trip. A keystroke typed while
// the push was in flight is then the local side of the merge rather
// than something the server's copy silently overwrites.
store.apply(merge(base, store.local(local.id), server))
}
// Then pull a page at a time. A new device asking for a hundred
// thousand notes in one response times out, and a crash halfway
// through costs one page rather than the whole sync.
do {
val page = api.since(store.cursor)
page.notes.forEach { store.apply(merge(store.base(it.id), store.local(it.id), it)) }
store.cursor = page.cursor
} while (page.hasMore)
}
}
package com.androidinterview.notes.sync
import com.androidinterview.notes.data.NoteStore
import com.androidinterview.notes.model.Note
data class Page(val notes: List<Note>, val cursor: Long, val hasMore: Boolean)
interface NoteApi {
suspend fun push(note: Note, baseRevision: Long): Note
suspend fun since(cursor: Long): Page
}
class NoteSync(private val store: NoteStore, private val api: NoteApi) {
suspend fun syncOnce() {
store.dirty().forEach { local ->
val base = store.base(local.id)
val server = api.push(local, base?.revision ?: 0)
store.apply(merge(base, store.local(local.id), server))
}
do {
val page = api.since(store.cursor)
page.notes.forEach { store.apply(merge(store.base(it.id), store.local(it.id), it)) }
store.cursor = page.cursor
} while (page.hasMore)
}
}
Attachments have their own upload queue, file references and size limits, with support for resuming. Saving text should not wait for a photo upload. Deletion markers stay until the server's sync rules say they can be removed safely. Pending edits are user data, so cache cleanup must never remove them.
Interview walkthrough and checks
I'd finish by tracing one edit. The user taps Save, the ViewModel calls the repository, and Room saves the note and its upload operation. The list updates immediately. A worker uploads later when its conditions are met. The server response clears only that operation. Another device downloads the change and merges it into Room before its UI updates.
| Scenario to test | Required result |
|---|---|
| Save offline and restart the process | Note and pending mutation remain |
| Edit while an earlier upload is in flight | Newer text and its outbox operation survive |
| Server commits but response is lost | Retry returns the same operation result |
| Crash during pull | Rows and cursor advance together or neither does |
| Edit on two devices | Separate fields merge, conflicting text is recoverable |
| Rotate, navigate away and return | No duplicate subscriptions or accidental saves |
| Logout during sync | No writes or reads leak into the next account |
| Delete on one device and edit on another | Documented tombstone policy applies |
I'd use fake repositories for ViewModel tests, a Room database in memory for DAO tests and a controllable HTTP server for timeouts and conflicting versions. Compose tests cover empty, error and saved states. I'd measure save time, the age of the oldest pending change, sync success, conflicts and dropped frames while scrolling. In the interview, I'd explain the read and save paths first, then go deeper into the failure case the interviewer chooses.
Read more Offline first apps (opens in a new tab)
Watch