androidinterview.com

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.

Notes actors and use casesThe system boundary Notes app holds the use cases Browse and search, Edit offline, Delete or restore, Sync devices, Resolve conflicts. Owner takes part in Browse and search, Edit offline, Delete or restore, Resolve conflicts. Sync service takes part in Sync devices.
Notes actors and use cases, a use case diagram for Notes app

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

Notes components and responsibilitiesCompose route and screen to NotesViewModel, events. NotesViewModel to NotesRepository, observe and save. NotesRepository to Room and DAOs, Flow and transactions. SyncWorker to Room and DAOs, merge saved changes. SyncWorker to Notes API, push and pull.eventsobserve and saveFlow and transactionsmerge saved changespush and pullCompose route and screenState down, actions upNotesViewModelUiState and screen actionsNotesRepositoryRead and save local notesSyncWorkerSaved work and retriesRoom and DAOsNotes and sync recordsNotes APIVersions and safe retries
Notes components and responsibilities
Arrows show calls. Room observations return through the repository and ViewModel to Compose. A repository save schedules the worker after committing its transaction.

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.

ComponentWhat it does
Route composableGets the ViewModel, collects state and handles navigation
Screen composableDraws the state and sends actions through callbacks
ViewModelHolds screen state, validates input and starts save calls
RepositoryReads local data and saves a note with its upload operation
DAORuns Room queries and writes database rows
Sync engine and workerUploads 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 contentType for 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. LazyColumn only draws needed rows, but a large backing List still 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.

  • @HiltAndroidApp starts Hilt from the application.
  • @AndroidEntryPoint lets the Activity receive dependencies.
  • @HiltViewModel and constructor @Inject tell Hilt how to create the ViewModel. The earlier state and action methods stay the same.
  • A @Module with @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-compose and coroutines for state and collection.
  • Compose UI, foundation and Material for the screen.
  • For Hilt, its Gradle plugin and compiler, hilt-lifecycle-viewmodel-compose and 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 testRequired result
Save offline and restart the processNote and pending mutation remain
Edit while an earlier upload is in flightNewer text and its outbox operation survive
Server commits but response is lostRetry returns the same operation result
Crash during pullRows and cursor advance together or neither does
Edit on two devicesSeparate fields merge, conflicting text is recoverable
Rotate, navigate away and returnNo duplicate subscriptions or accidental saves
Logout during syncNo writes or reads leak into the next account
Delete on one device and edit on anotherDocumented 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