Connect with us

Разработка

Разработка системы автоматической синхронизации фотографий: Android System Design

От ContentObserver до загрузок из многих частей в S3 — полный системный дизайн, охватывающий обнаружение новых файлов, устойчивость к работе без сети, оптимизацию энергопотребления и масштабируемую облачную архитектуру серверной части.

Опубликовано

/

     
     

Представьте: вы проходите собеседование на позицию Staff Android Engineer, и интервьюер наклоняется вперёд и говорит:

«Расскажите, как бы вы спроектировали систему автоматической синхронизации, похожую на Google Photos. Меня интересует всё: обнаружение новых фотографий, загрузка, работа без сети, влияние на батарею и серверная часть».

Это один из лучших вопросов по системному дизайну для Staff Android Engineer, потому что он затрагивает каждый уровень системы. Здесь недостаточно просто написать фоновый сервис — нужно одновременно учитывать постоянно меняющиеся ограничения Android, надёжность распределённых систем, особенности энергопотребления и доверие пользователей.

В этой статье я приведу ответ, который дал бы сам. Разберём систему уровень за уровнем.

1. Требования — сначала определите границы задачи

Первое, что делает Staff-инженер, — определяет область задачи. Требования — это не простая формальность. Именно от них зависят все последующие архитектурные решения.

Функциональные требования

FR-1 — автоматически определять, когда новая фотография сделана камерой или сохранена в галерею.

FR-2 — загружать фотографии в исходном качестве в облако без сжатия с потерями.

FR-3 — сохранять состояние синхронизации локально, чтобы загрузки продолжались после завершения процесса приложения, перезагрузки устройства и потери сети.

FR-4 — поддерживать возобновляемые загрузки: если загрузка RAW-файла размером 15 МБ прервалась на 80%, она должна продолжиться с соответствующего смещения в байтах.

FR-5 — предоставить пользователю настраиваемые параметры синхронизации: только через Wi-Fi, только во время зарядки, фильтрация папок.

FR-6 — синхронизировать фотографии обратно на новое устройство или устройство после сброса настроек — сценарий восстановления.

FR-7 — отображать состояние загрузки: в очереди, загружается с указанием процента, синхронизировано, ошибка.

FR-8 — выполнять дедупликацию: не загружать одно и то же изображение дважды, даже если MediaStore дважды отправил уведомление об изменении.

Нефункциональные требования

NFR-1 — Надёжность: 99,9% фотографий должны в итоге попасть на сервер при любых условиях.

NFR-2 — Энергопотребление: приложение не должно появляться среди основных потребителей энергии в системной статистике. Система должна корректно работать с режимом Doze.

NFR-3 — Задержка: загрузка фотографии, сделанной при подключении к Wi-Fi, должна начаться в течение 30 секунд.

NFR-4 — Использование трафика: по умолчанию загрузка выполняется только через Wi-Fi; при разрешённом использовании мобильной сети качество должно адаптироваться к условиям подключения.

NFR-5 — Безопасность: TLS 1.3 при передаче данных, AES-256 при хранении, отдельные подписанные URL для каждого файла.

NFR-6 — Конфиденциальность: возможность удаления EXIF-метаданных и удаление данных в соответствии с GDPR.

2. Обнаружение новой фотографии — всё сложнее, чем кажется

Именно на этом этапе многие кандидаты чрезмерно упрощают решение. В Android не существует единственного идеального механизма обнаружения новых фотографий. Нужен многоуровневый подход, поскольку у каждого механизма есть свои ограничения.

Уровень 1: ContentObserver — быстро, но ненадёжно

Стандартный подход заключается в регистрации ContentObserver для URI MediaStore.Images.Media.EXTERNAL_CONTENT_URI.

Когда изображение добавляется, изменяется или удаляется, операционная система вызывает ваш наблюдатель. Событие обычно поступает через несколько секунд после создания фотографии — это именно то, что требуется для основного успешного сценария.

class MediaStoreObserver(
    private val context: Context,
    private val onNewMedia: (Uri) -> Unit
) : ContentObserver(Handler(Looper.getMainLooper())) {

    fun register() {
        context.contentResolver.registerContentObserver(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            true, // observe subtree
            this
        )
    }

    override fun onChange(selfChange: Boolean, uri: Uri?) {
        uri ?: return
        // Verify it's a new image, not an edit or deletion
        context.contentResolver.query(
            uri,
            arrayOf(MediaStore.Images.Media._ID,
                    MediaStore.Images.Media.DATE_ADDED,
                    MediaStore.Images.Media.SIZE),
            null, null, null
        )?.use { cursor ->
            if (cursor.moveToFirst()) onNewMedia(uri)
        }
    }
}

⚠️ Ловушка Scoped Storage в Android 10 и новее: вы не можете свободно читать данные через EXTERNAL_CONTENT_URI без разрешения READ_MEDIA_IMAGES. Начиная с Android 13, разрешение READ_EXTERNAL_STORAGE полностью устарело. Всегда запрашивайте подходящее разрешение в рантайме и корректно обрабатывайте отказ пользователя, показывая объяснение необходимости разрешения.

Уровень 2: периодическое сканирование через WorkManager — надёжный резервный механизм

ContentObserver получает события только пока процесс приложения активен. Если система завершит приложение — а на устройствах с небольшим объёмом памяти она будет делать это довольно агрессивно, — некоторые фотографии останутся необнаруженными.

Поэтому наблюдатель необходимо дополнить периодической задачей WorkManager, которая сканирует фотографии с полем DATE_ADDED, превышающим временную метку последней успешной синхронизации.

class MediaScanWorker(
    ctx: Context,
    params: WorkerParameters
) : CoroutineWorker(ctx, params) {

    override suspend fun doWork(): Result {
        val lastSyncTs = localDb.getLastSyncTimestamp()

        val newImages = queryMediaStore(
            selection = "${MediaStore.Images.Media.DATE_ADDED} > ?",
            selectionArgs = arrayOf(lastSyncTs.toString())
        )

        newImages.forEach { image ->
            // Idempotent — checksum prevents duplicates
            if (!localDb.existsInSyncQueue(image.checksum)) {
                localDb.insertToSyncQueue(image.toSyncEntity())
            }
        }

        return Result.success()
    }
}

ContentObserver обеспечивает скорость, а WorkManager — надёжность. Вам нужны оба механизма: если использовать только один из них, рано или поздно часть фотографий будет пропущена.

Архитектура на одной схеме

Разработка системы автоматической синхронизации фотографий: Android System Design

3. Локальная база данных — ваш контракт на надёжность

Локальная база данных Room — основа всей системы. Её можно рассматривать как реализацию шаблона Outbox: каждая фотография сначала записывается в базу данных и только после этого отправляется по сети.

Именно такой подход делает систему действительно надёжной, а не хрупкой архитектурой, которая лишь рассчитывает на благоприятный сценарий.

@Entity(tableName = "sync_queue")
data class SyncItem(
    @PrimaryKey val mediaStoreId: Long,    // MediaStore._ID (stable)
    val localUri: String,                   // content:// URI
    val mimeType: String,                   // image/jpeg, image/heic
    val sizeBytes: Long,
    val dateAdded: Long,
    val checksum: String,                   // SHA-256 for dedup
    val serverFileId: String? = null,       // Set after server confirms
    val uploadedBytes: Long = 0,           // For chunked resume
    val uploadUrl: String? = null,         // Cached presigned URL
    val status: SyncStatus = SyncStatus.PENDING,
    val retryCount: Int = 0,
    val lastAttemptAt: Long? = null
)

enum class SyncStatus {
    PENDING, UPLOADING, PAUSED, SYNCED, FAILED, DUPLICATE
}

💡 Подход уровня Staff-инженера — дедупликация на основе содержимого: всегда вычисляйте контрольную сумму SHA-256 перед добавлением файла в очередь. Передавайте этот хеш с каждым запросом на загрузку. Если на сервере уже есть файл с таким хешем, он сразу возвращает ответ 409 Already Exists, и повторная загрузка не выполняется.

Именно так Dropbox реализует дедупликацию. Такой подход отличает просто хорошую реализацию от действительно качественной.

4. Конвейер загрузки — с учётом всех возможных сбоев

Наивная реализация вызывает OkHttp, загружает файл и надеется, что всё пройдёт успешно.

Реализация уровня Staff-инженера изначально предполагает, что загрузка обязательно прервётся — в середине файла и на любом смещении в байтах. Поэтому весь конвейер проектируется с учётом неизбежности такого сбоя.

Photo captured
     │
     ▼
SyncOrchestrator (is it safe to upload?)
  • Wi-Fi connected?  • Battery ≥ 20%?
  • User constraints satisfied?
     │
     ▼
UploadWorker (WorkManager)
  Step 1 → Compute SHA-256 checksum
  Step 2 → Request presigned upload URL from API
           (Server checks dedup, returns S3 URL)
  Step 3 → Chunked multipart PUT to S3
           (5 MB chunks, progress persisted after each)
  Step 4 → Confirm completion with API
  Step 5 → Mark DB row as SYNCED ✓

Никогда не передавайте файлы через API-сервер

Клиент запрашивает у серверной части краткоживущий предварительно подписанный URL, после чего загружает файл напрямую в S3 или GCS. При таком подходе API-сервер не участвует в передаче содержимого файла и не превращается в узкое место по пропускной способности. Это стандартная отраслевая схема, которую используют Dropbox, Google Photos и другие серьёзные сервисы хранения файлов.

interface SyncApiService {
    @POST("v1/photos/upload-url")
    suspend fun requestUploadUrl(
        @Body request: UploadUrlRequest
    ): UploadUrlResponse
}

data class UploadUrlResponse(
    val alreadyExists: Boolean, // Server dedup shortcut
    val serverFileId: String,
    val uploadUrl: String,      // Presigned S3 URL (1hr TTL)
    val uploadId: String?       // For multipart sessions
)

Загрузка частями с возобновлением с точным смещением в байтах

Это основа всего конвейера загрузки — класс, который разбивает большую фотографию на части по 5 МБ и отправляет их в S3.

Разберём каждое принятое в нём решение.

class ChunkedUploader(
    private val okHttpClient: OkHttpClient,
    private val contentResolver: ContentResolver,
    private val localDb: SyncQueueDao
) {
    private val CHUNK_SIZE = 5 * 1024 * 1024L // 5 MB

    suspend fun upload(
        localUri: Uri,
        uploadUrl: String,
        mediaStoreId: Long,
        startByte: Long = 0,
        onProgress: (uploaded: Long, total: Long) -> Unit
    ) = withContext(Dispatchers.IO) {

        val stream = contentResolver.openInputStream(localUri)
            ?: throw IOException("Cannot open file: $localUri")

        try {
            stream.skip(startByte)

            var bytesUploaded = startByte
            val totalSize = getFileSize(localUri)
            val buffer = ByteArray(CHUNK_SIZE.toInt()) // allocated once, reused

            while (true) {
                val bytesRead = stream.read(buffer)
                if (bytesRead == -1) break   // end of file

                putChunk(uploadUrl, buffer, bytesRead, bytesUploaded, totalSize)

                bytesUploaded += bytesRead
                onProgress(bytesUploaded, totalSize)

                // Write resume point AFTER successful chunk — not before
                localDb.updateProgress(mediaStoreId, SyncStatus.UPLOADING, bytesUploaded)
            }
        } finally {
            stream.close() // always release, even on exception
        }
    }
}

Почему части по 5 МБ?

Значение 5 * 1024 * 1024L выбрано не случайно — это минимальный размер части, который AWS S3 принимает при многокомпонентной загрузке. Если отправить часть меньшего размера, S3 отклонит запрос. Суффикс L указывает, что результат имеет тип Long, а не Int. Это важно, поскольку Int переполняется при значениях больше примерно 2 ГБ. При работе с большими видеофайлами такое переполнение могло бы привести к отрицательному размеру части.

Механизм возобновления — startByte

При первой загрузке значение startByte равно 0. Предположим, предыдущая попытка успела загрузить 10 МБ из файла размером 20 МБ, после чего операционная система завершила процесс приложения. При следующем запуске задачи WorkManager считывает значение uploadedBytes из базы данных Room и передаёт его как startByte = 10485760.

Затем поток пропускает уже загруженные байты:

stream.skip(startByte) // moves file pointer — does NOT load bytes into memory

Это эффективный подход. Метод skip() перемещает внутренний указатель файла, не считывая данные в оперативную память. Благодаря этому уже загруженная в S3 часть файла не обрабатывается повторно.

Буфер — создаётся один раз и повторно используется в каждой итерации

val buffer = ByteArray(CHUNK_SIZE.toInt()) // 5 MB allocated exactly once
while (true) {
    val bytesRead = stream.read(buffer) // fills buffer, returns how many bytes came in
    if (bytesRead == -1) break         // -1 means end of file
    ...
}

Буфер создаётся за пределами цикла. Если объявить его внутри, на каждой итерации будет выделяться новый объект размером 5 МБ, а затем передаваться сборщику мусора. На мобильном устройстве с ограниченными ресурсами это создаст значительную нагрузку на GC. Если создать буфер один раз до начала цикла, одна и та же область памяти будет повторно использоваться для каждой части файла.

Метод stream.read(buffer) заполняет буфер и возвращает фактическое количество записанных в него байтов. Последняя часть файла почти никогда не будет иметь размер ровно 5 МБ. Например, файл размером 12 МБ будет разбит следующим образом:

Chunk 1 → bytesRead = 5,242,880  (full 5 MB)
Chunk 2 → bytesRead = 5,242,880  (full 5 MB)
Chunk 3 → bytesRead = 2,097,152  (partial — only 2 MB left)
Chunk 4 → bytesRead = -1         (EOF — loop exits)

Именно поэтому значение bytesRead передаётся в putChunk. Без него последний запрос PUT отправил бы весь буфер размером 5 МБ, включая 3 МБ устаревших данных, оставшихся после предыдущей итерации. В результате файл в S3 оказался бы повреждён.

Что на самом деле делает putChunk

Эта функция формирует HTTP-запрос и отправляет очередную часть файла в S3.

Заголовок Content-Range указывает точное положение этой части внутри итогового собранного файла.

private fun putChunk(
    url: String,
    buffer: ByteArray,
    bytesRead: Int,
    bytesUploaded: Long,
    totalSize: Long
) {
    val endByte = bytesUploaded + bytesRead - 1

    val request = Request.Builder()
        .url(url)
        .put(
            buffer
                .copyOfRange(0, bytesRead)  // only the real bytes, not the full 5 MB buffer
                .toRequestBody("application/octet-stream".toMediaType())
        )
        .addHeader("Content-Range", "bytes $bytesUploaded-$endByte/$totalSize")
        .build()

    val response = okHttpClient.newCall(request).execute()
    if (!response.isSuccessful) {
        throw IOException("Chunk upload failed: HTTP ${response.code}")
    }
}

Для файла размером 12 МБ три заголовка Content-Range будут выглядеть следующим образом:

Chunk 1 → Content-Range: bytes 0-5242879/12582912
Chunk 2 → Content-Range: bytes 5242880-10485759/12582912
Chunk 3 → Content-Range: bytes 10485760-12582911/12582912

Самое важное — постоянный прогресс после каждого этапа

bytesUploaded += bytesRead
onProgress(bytesUploaded, totalSize)      // updates the UI progress bar

// Write to DB AFTER upload succeeds — never before
localDb.updateProgress(mediaStoreId, SyncStatus.UPLOADING, bytesUploaded)

Порядок операций здесь выбран намеренно и имеет критическое значение. Если записать состояние в базу данных до завершения загрузки, сбой в середине части приведёт к рассинхронизации: база данных будет считать, что загружено 10 МБ, хотя S3 фактически получил только 5 МБ. При возобновлении загрузка начнётся с неправильного смещения, и итоговый файл окажется повреждён.

Записывайте состояние в базу данных только после того, как S3 подтвердил успешную загрузку части. Строка в Room — это ваш контракт восстановления. Она означает: «Все данные до этого смещения гарантированно сохранены на сервере».

⚠️ Недостаток этой реализации для production-среды: если загрузка одной части завершается ошибкой из-за нестабильной сети или временного сбоя сервера, вся операция прерывается, а WorkManager повторяет её с последнего сохранённого значения bytesUploaded. Такой подход корректен, но для повторной отправки одной неудачной части приходится полностью перезапускать Worker. Более точная реализация должна оборачивать putChunk в отдельный цикл повторных попыток для каждой части с экспоненциальной задержкой. Например, повторять отправку конкретного фрагмента размером 5 МБ до трёх раз и только после этого передавать ошибку на верхний уровень.

5. Управление размером файлов — хранение и сетевой трафик

Staff-инженер чётко разделяет оптимизацию хранения — то, в каком виде данные находятся на сервере, — и оптимизацию сетевого трафика — то, как именно они передаются. Цель состоит в том, чтобы всегда сохранять оригиналы, но при этом разумно выбирать время и способ их загрузки.

Стратегия адаптивного качества

При подключении через мобильную сеть сначала загружайте сжатую миниатюру для предварительного просмотра — например, JPEG размером около 80 КБ. Благодаря этому фотография почти сразу появится в веб-интерфейсе.

После этого поставьте оригинал в очередь на загрузку через Wi-Fi. Именно такой подход использует Google Photos: изображение становится доступно сразу, а полная версия в исходном качестве загружается позже.

fun determineStrategy(
    network: NetworkState,
    prefs: UserPreferences
): UploadStrategy = when {
    network.isWifi                         -> UploadStrategy.FullQuality
    network.isMetered && prefs.dataSaver   -> UploadStrategy.PreviewOnly
    network.isMetered                      -> UploadStrategy.AdaptiveQuality(
                                                previewQuality = 75,
                                                uploadOriginalOnWifi = true
                                             )
    else                                   -> UploadStrategy.AdaptiveQuality()
}

Разработка системы автоматической синхронизации фотографий: Android System Design

6. Оптимизация энергопотребления — самая сложная часть

Операционной системе нет дела до вашей бизнес-логики. Она может без предупреждения завершить загрузку в середине файла. Проектируйте каждую загрузку так, будто процесс может быть завершён в ближайшие пять секунд.

Именно из-за высокого расхода заряда приложения для синхронизации фотографий получают отзывы с одной звездой. Механизмы энергосбережения Android — режим Doze, категории App Standby Buckets и ограничения фоновых процессов — активно препятствуют выполнению фоновых задач. Рассмотрим, как спроектировать систему с учётом этих ограничений.

WorkManager — единственно правильное решение

WorkManager заменяет JobScheduler, Firebase JobDispatcher, обходные решения на основе AlarmManager и другие нестандартные подходы, которые разработчики использовали до его появления. Он учитывает режим Doze, продолжает работу после перезагрузки устройства и соблюдает системные ограничения ресурсов. Для фоновой загрузки файлов нет альтернативы, которую стоило бы всерьёз рассматривать.

Но есть важный момент, на котором часто ошибаются во время собеседований: WorkManager сам по себе ничего не отслеживает. Это только исполнитель задач. Другой компонент должен вызвать enqueue() и передать ему работу. В нашей системе это делают три механизма. Точное понимание того, какой из них и в какой момент срабатывает, отличает надёжную систему от реализации, которая незаметно пропускает фотографии.

Общая функция постановки задачи в очередь — вызывается всеми тремя триггерами

Все три триггера в итоге вызывают одну и ту же функцию. Политика ExistingWorkPolicy.KEEP защищает от дублирования задач. Если ContentObserver дважды сработает для одной фотографии — а MediaStore иногда ведёт себя именно так, — второй вызов ничего не сделает.

Одна фотография — одна задача. Всегда.

fun enqueueUploadWork(context: Context, mediaStoreId: Long) {

    val constraints = Constraints.Builder()
        .setRequiredNetworkType(
            if (prefs.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED
        )
        .setRequiresBatteryNotLow(true)
        .build()

    val request = OneTimeWorkRequestBuilder<UploadWorker>()
        .setConstraints(constraints)
        .setInputData(workDataOf("mediaStoreId" to mediaStoreId))
        .setBackoffCriteria(
            BackoffPolicy.EXPONENTIAL,
            WorkRequest.MIN_BACKOFF_MILLIS,
            TimeUnit.MILLISECONDS
        )
        .build()

    // One job per photo — second enqueue for same ID is silently ignored
    WorkManager.getInstance(context).enqueueUniqueWork(
        "upload_$mediaStoreId",
        ExistingWorkPolicy.KEEP,
        request
    )
}

Триггер 1 — ContentObserver: мгновенное срабатывание, пока приложение активно

Когда пользователь делает фотографию и процесс приложения активен, метод onChange() вызывается в течение нескольких секунд.

Сам ContentObserver не выполняет тяжёлую работу. Он только записывает фотографию в локальную очередь Outbox, ставит задачу в очередь и сразу завершает выполнение.

Всю дальнейшую работу берёт на себя WorkManager.

override fun onChange(selfChange: Boolean, uri: Uri?) {
    uri ?: return
    val newImage = queryMediaStore(uri) ?: return

    // Step 1: write to outbox — network is irrelevant at this point
    localDb.insertToSyncQueue(newImage.toSyncEntity())

    // Step 2: hand off to WorkManager — constraints checked, retry handled
    enqueueUploadWork(context, newImage.mediaStoreId)
}

Триггер 2 — MediaScanWorker: периодический поиск фотографий, пропущенных во время остановки приложения

Это PeriodicWorkRequest, который один раз регистрируется при запуске приложения и выполняется каждые 15 минут. Его задача — находить фотографии, добавленные в тот момент, когда ContentObserver не работал: например, если система завершила процесс приложения или пользователь принудительно остановил его. MediaScanWorker запрашивает у MediaStore все фотографии, добавленные позже временной метки последней зафиксированной синхронизации, и ставит отдельную задачу в очередь для каждого пропущенного файла.

class MediaScanWorker(ctx: Context, params: WorkerParameters)
    : CoroutineWorker(ctx, params) {

    override suspend fun doWork(): Result {
        val newImages = queryMediaStoreSince(localDb.getLastSyncTimestamp())

        newImages.forEach { image ->
            if (!localDb.existsInSyncQueue(image.checksum)) {
                localDb.insertToSyncQueue(image.toSyncEntity())
                enqueueUploadWork(applicationContext, image.mediaStoreId)
            }
        }
        return Result.success()
    }
}

// Registered once at app startup — runs on schedule forever
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "media_scan",
    ExistingPeriodicWorkPolicy.KEEP,
    PeriodicWorkRequestBuilder<MediaScanWorker>(15, TimeUnit.MINUTES)
        .setConstraints(constraints).build()
)

Почему для каждой фотографии создаётся отдельный OneTimeWorkRequest, а не одна большая задача для всех файлов?

Одна задача на фотографию Одна задача для всех фотографий
Если фотография №3 не загрузилась Повторяется только загрузка №3, а фотографии №4–50 продолжают загружаться Вся пакетная загрузка заблокирована, пока фотография №3 не будет успешно загружена
Задержка перед повторной попыткой Настраивается независимо для каждой фотографии Один сбой задерживает загрузку всех фотографий
Отображение прогресса Статус каждой фотографии отдельно отображается в WorkManager Единый непрозрачный статус «идёт загрузка»
Отмена Можно отменить загрузку одной фотографии, не затрагивая остальные Можно отменить либо всё, либо ничего

Категории App Standby Buckets — учитывайте доступный бюджет фонового выполнения

Разработка системы автоматической синхронизации фотографий: Android System Design

7. Синхронизация без сети — шаблон Outbox

Архитектура offline-first рассматривает подключение к сети как дополнительную возможность, а не как обязательное условие работы. Пользователь делает фотографию в авиарежиме где-нибудь в горах. Когда связь снова появится, эта фотография всё равно должна попасть в облако. Ключевой момент заключается в следующем: после восстановления сети мы не ждём, пока ContentObserver сработает повторно. ContentObserver сообщает только о новых фотографиях. Он ничего не знает о файлах, которые уже находятся в базе данных Room со статусом PENDING. Для них нужен совершенно другой триггер.

Механизм, объединяющий всю систему, — шаблон Outbox: каждая фотография записывается в базу данных Room ещё до первого обращения к сети. База данных обеспечивает постоянное хранение состояния. Очередь задач WorkManager такой гарантии не даёт. Именно это различие позволяет корректно возобновлять синхронизацию.

Сценарий A — приложение активно, сеть пропала во время работы

Это самый простой случай. Задача уже находится в WorkManager.

Когда подключение восстанавливается, WorkManager определяет, что заданные ограничения снова выполняются, и автоматически возобновляет задачу.

NetworkMonitor в этом сценарии ничего не делает — WorkManager полностью обрабатывает восстановление сети самостоятельно.

Photo taken (app alive, no network)
    │
    ▼
ContentObserver → Room DB: PENDING → enqueueUploadWork()
    │
    ▼
WorkManager creates job, parks it   ← constraints not met
    │
    ▼  (network returns)
WorkManager detects constraints met ← wakes itself up, no help needed
    │
    ▼
UploadWorker runs ✓

Сценарий B — фотография сделана, когда приложение было полностью остановлено

ContentObserver не сработал, потому что процесса приложения не существовало. Поэтому фотография ещё не записана в базу данных Room. Периодический MediaScanWorker, выполнение которого WorkManager поддерживает даже при отсутствии процесса приложения, обнаруживает фотографию во время следующего запуска через 15 минут, записывает её в Room и ставит задачу загрузки в очередь.

Photo taken (app process dead — ContentObserver never fires)
    │
    Room DB: nothing written yet
    │
    ▼  (up to 15 minutes later)
MediaScanWorker runs (PeriodicWork — survives process death)
    │
    ▼
Queries MediaStore WHERE DATE_ADDED > last_sync_timestamp
    │
    ▼
Room DB: PENDING → enqueueUploadWork()
    │
    ▼
If network available → UploadWorker runs immediately
If no network       → job parked until constraints met

Сценарий C — процесс завершён во время загрузки: восстановление после сбоя

Именно для такого сценария и был создан WorkManager. Если операционная система завершит процесс Worker до того, как он вернёт Result.success(), WorkManager автоматически повторно запланирует ту же задачу. После перезапуска Worker считывает из базы данных Room значение uploadedBytes и возобновляет загрузку с точного смещения в байтах. Отдельный код восстановления писать не требуется — это поведение гарантирует сам WorkManager.

ContentObserver → Room DB: PENDING → enqueueUploadWork()
    │
    ▼
UploadWorker starts
Room DB: UPLOADING, uploadedBytes = 5 MB
    │
OS kills the process (low memory / user swipes away)
    │
    ▼
WorkManager detects worker didn't return Result.success()
    │
    ▼
WorkManager automatically reschedules the same job  ← no code needed
    │
    ▼
UploadWorker restarts → reads uploadedBytes = 5 MB from Room DB
    │
    ▼
Resumes from byte 5,242,880 ✓

Сценарий D — пробел в логике: приложение завершено без сети, а затем подключение восстанавливается

Именно этого сценария не хватало на предыдущей схеме, и именно поэтому NetworkMonitor повторно создаёт задачи на основе данных из Room. Фотография была сделана без подключения к сети, ContentObserver записал её в базу данных Room, а WorkManager создал задачу, ожидающую появления сети. После этого процесс приложения был завершён. Очередь задач WorkManager действительно сохраняется во внутренней базе данных SQLite. Однако на практике задачи могут быть потеряны, если приложение находится в категории RARE механизма App Standby Buckets, после полной перезагрузки устройства с очисткой устаревших задач или из-за некоторых механизмов энергосбережения производителей устройств, которые полностью удаляют отложенные задания.

Когда сеть восстанавливается в таком состоянии, у WorkManager может не оказаться задачи, которую можно возобновить. ContentObserver не сработает, поскольку новая фотография не была создана. А до следующего запуска MediaScanWorker может пройти до 15 минут. Этот пробел и закрывает метод NetworkMonitor.onAvailable(): он напрямую читает данные из базы Room и повторно создаёт все потерянные задачи.

Photo taken while offline (app alive)
    │
    ▼
ContentObserver → Room DB: PENDING → enqueueUploadWork()
    │
WorkManager parks job (no network)
    │
App process killed while still offline
    │
WorkManager job queue lost  ←  Room DB still has PENDING row (survives)
    │
    ▼  (network returns)
WorkManager: no job to wake up
ContentObserver: won't fire (no new photo)
MediaScanWorker: up to 15 min away
    │
    ▼
NetworkMonitor.onAvailable() fires
    │
    ▼
Reads Room DB: WHERE status = PENDING   ← the survivor
    │
    ▼
enqueueUploadWork()                     ← re-creates the lost job
    │
    ▼
UploadWorker runs ✓

Триггер — NetworkMonitor: механизм, закрывающий пробел

Теперь назначение этого кода становится полностью понятным. Метод onAvailable() обрабатывает сценарий D. Когда подключение к сети восстанавливается, он проверяет базу данных Room и повторно ставит в очередь задачи, которые могли быть потеряны. Метод onLost() переводит все выполняющиеся загрузки в состояние PAUSED, чтобы сохранить их текущее смещение в байтах и затем корректно возобновить передачу. Таким образом, управление возвращается к механизму восстановления из сценария C.

class NetworkMonitor(
    private val context: Context,
    private val localDb: SyncQueueDao
) {
    fun startMonitoring(scope: CoroutineScope) {
        val cm = context.getSystemService(ConnectivityManager::class.java)

        val callback = object : ConnectivityManager.NetworkCallback() {

            override fun onAvailable(network: Network) {
                scope.launch {
                    // Go straight to Room DB — do NOT wait for ContentObserver
                    val pendingItems = localDb.getPendingAndPausedItems()

                    if (pendingItems.isEmpty()) return@launch

                    pendingItems.forEach { item ->
                        // Re-enqueue each waiting photo
                        enqueueUploadWork(context, item.mediaStoreId)
                    }
                }
            }

            override fun onLost(network: Network) {
                scope.launch {
                    // Mark any UPLOADING items as PAUSED so they resume cleanly
                    localDb.pauseActiveUploads()
                }
            }
        }

        cm.registerNetworkCallback(
            NetworkRequest.Builder()
                .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
                .build(),
            callback
        )
    }
}

Запрос к базе данных Room, обеспечивающий восстановление

В DAO нужен запрос, который получает все фотографии, всё ещё ожидающие загрузки — независимо от того, были ли они добавлены ContentObserver пять минут назад или находятся в очереди уже три дня, пока пользователь работал без подключения к сети:

@Dao
interface SyncQueueDao {

    // Called by NetworkMonitor when connection returns
    @Query("SELECT * FROM sync_queue WHERE status IN ('PENDING', 'PAUSED') ORDER BY dateAdded ASC")
    suspend fun getPendingAndPausedItems(): List<SyncItem>

    // Called by NetworkMonitor when connection drops
    @Query("UPDATE sync_queue SET status = 'PAUSED' WHERE status = 'UPLOADING'")
    suspend fun pauseActiveUploads()

    // Called by UploadWorker on crash recovery — stalled jobs
    @Query("SELECT * FROM sync_queue WHERE status = 'UPLOADING' AND lastAttemptAt < :staleCutoff")
    suspend fun getStalledUploads(staleCutoff: Long): List<SyncItem>
}

Полная карта триггеров — четыре сценария, четыре ответственных компонента

За каждую возможную ситуацию отвечает ровно один компонент. Механизмы не дублируют друг друга, и в архитектуре не остаётся пробелов.

SCENARIO                              WHO ACTS           MECHANISM

A: Photo taken, app alive, net ok  →  ContentObserver →  DB + enqueue()
                                                          WorkManager runs immediately

B: Photo taken, app dead           →  MediaScanWorker →  scan MediaStore
                                      (every 15 min)     DB + enqueue()

C: Process killed mid-upload       →  WorkManager     →  auto-reschedules itself
                                      (built-in)         resumes from uploadedBytes

D: App killed offline, net returns →  NetworkMonitor  →  reads Room DB directly
                                      onAvailable()      re-creates lost jobs

🔑 Ключевая идея: очередь задач WorkManager — это механизм выполнения, а не слой хранения данных. Единственным постоянным источником информации о том, какие файлы ещё нужно загрузить, является база данных Room. Сценарии A и C WorkManager обрабатывает самостоятельно. NetworkMonitor нужен исключительно для сценария D — когда очередь WorkManager была потеряна, а данные в Room сохранились. Если отказаться от NetworkMonitor, приложение будет незаметно пропускать фотографии каждый раз, когда его процесс завершается во время отсутствия сети. Пользователь может обнаружить это только позже, например при проверке резервной копии на новом устройстве.

8. Архитектура Android-приложения — Clean Architecture + MVI

Для системы такой сложности — с фоновыми Worker, обновлениями базы данных в реальном времени, реактивным пользовательским интерфейсом и несколькими источниками данных — архитектура должна быть одновременно последовательной и практичной.

Я бы рекомендовал использовать Clean Architecture в сочетании с MVI на уровне представления.

┌─────────────────────────────────────────────────────────────┐
│                     PRESENTATION                            │
│   SyncFragment ◄──── SyncViewModel (MVI)                    │
│        │                    │                               │
│   Renders UiState      Emits Intent                         │
└─────────────────────────────┬───────────────────────────────┘
                              │  (UseCase calls only)
┌─────────────────────────────▼───────────────────────────────┐
│                       DOMAIN                                │
│   GetSyncStatusUseCase    ScheduleUploadUseCase             │
│   GetPendingCountUseCase  PauseAllUploadsUseCase            │
└─────────────────────────────┬───────────────────────────────┘
                              │  (Repository interface)
┌─────────────────────────────▼───────────────────────────────┐
│                        DATA                                 │
│   SyncRepository (impl)                                     │
│        ├── Room DB (SyncQueueDao)                           │
│        ├── Retrofit/OkHttp (SyncApiService)                 │
│        ├── S3ChunkedUploader                                │
│        └── MediaStoreSource (ContentObserver wrap)          │
└─────────────────────────────┬───────────────────────────────┘
                              │
┌─────────────────────────────▼───────────────────────────────┐
│                  BACKGROUND WORK                            │
│   WorkManager Workers (upload, scan, retry)                 │
│   NetworkMonitor (ConnectivityManager callbacks)            │
└─────────────────────────────────────────────────────────────┘
class SyncViewModel(
    private val getSyncStatus: GetSyncStatusUseCase
) : ViewModel() {

    private val _state = MutableStateFlow(SyncUiState())
    val state = _state.asStateFlow()

    init {
        // Room Flow re-emits automatically on every DB change
        getSyncStatus()
            .onEach { status -> _state.update {
                it.copy(
                    pending = status.pending,
                    synced  = status.synced,
                    current = status.uploading
                )
            }}
            .launchIn(viewModelScope)
    }

    fun handle(intent: SyncIntent) = when (intent) {
        is SyncIntent.Pause       -> pauseAll()
        is SyncIntent.RetryFailed -> retryFailed()
        is SyncIntent.SetWifiOnly -> updatePref(intent.enabled)
    }
}

9. Восстановление изображений в галерее

Сценарий восстановления часто упускают на собеседованиях по системному дизайну. Когда пользователь устанавливает приложение на новое устройство, он ожидает, что все его фотографии снова появятся в стандартной галерее. В Android 10 и новее это нетривиальная задача из-за Scoped Storage.

Ключевой механизм здесь — флаг IS_PENDING в MediaStore. Сначала вы добавляете запись со статусом ожидания, затем записываете байты файла и после завершения устанавливаете IS_PENDING в 0. До этого финального обновления фотография остаётся невидимой для других приложений. Это предотвращает появление в галерее частично записанных или повреждённых файлов.

suspend fun downloadAndSavePhoto(photo: ServerPhoto) {
    val values = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, photo.fileName)
        put(MediaStore.Images.Media.MIME_TYPE, photo.mimeType)
        put(MediaStore.Images.Media.DATE_TAKEN, photo.dateTaken)
        put(MediaStore.Images.Media.IS_PENDING, 1) // Hidden during write
    }

    val uri = contentResolver.insert(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values
    ) ?: throw IOException("MediaStore insert failed")

    try {
        contentResolver.openOutputStream(uri)?.use { stream ->
            downloadStream(photo.downloadUrl, stream)
        }
        // Atomic reveal — photo appears in Gallery
        values.clear()
        values.put(MediaStore.Images.Media.IS_PENDING, 0)
        contentResolver.update(uri, values, null, null)

    } catch (e: Exception) {
        contentResolver.delete(uri, null, null) // Cleanup on failure
        throw e
    }
}

10. Высокоуровневая архитектура

 

Android/iOS Clients
        │
        ▼
┌───────────────────┐
│   API Gateway     │  (AWS ALB / Kong)
│   Auth, Rate      │  TLS termination
│   Limiting        │
└────────┬──────────┘
         │
    ┌────┴────────────────┐
    ▼                     ▼
┌──────────┐       ┌──────────────┐
│   Sync   │       │   Metadata   │
│  Service │       │   Service    │
│(stateless│       │(PostgreSQL)  │
│ pods)    │       │              │
└──────┬───┘       └──────┬───────┘
       │                  │
       └──────┬───────────┘
              ▼
       ┌────────────┐
       │  AWS S3    │  Object storage
       │  Per-user  │  Lifecycle rules
       │  prefixes  │
       └─────┬──────┘
             │  S3 Event → SNS → SQS → Lambda
             ▼
    ┌──────────────────┐
    │ Async Processing │
    │ • Thumbnails     │
    │ • EXIF extract   │
    │ • Virus scan     │
    │ • CDN warm       │
    └──────────────────┘

Схема медиа

-- PostgreSQL: photos table
CREATE TABLE photos (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID NOT NULL REFERENCES users(id),
    device_id       TEXT NOT NULL,
    checksum        VARCHAR(64) UNIQUE NOT NULL, -- SHA-256, global dedup
    s3_key          TEXT NOT NULL,              -- user_id/year/month/uuid
    mime_type       TEXT NOT NULL,
    size_bytes      BIGINT,
    date_taken      TIMESTAMPTZ,
    exif_json       JSONB,                        -- separate for privacy
    thumbnail_s3_key TEXT,
    is_deleted      BOOLEAN DEFAULT FALSE,
INDEX idx_user_date (user_id, date_taken DESC),
    INDEX idx_checksum  (checksum)
);

Заключение

Лучшее приложение для резервного копирования фотографий — то, о работе которого пользователь забывает. Никаких уведомлений с просьбой повторно выдать уже предоставленные разрешения. Никаких жалоб на расход заряда. Никаких сообщений «Не удалось загрузить файл». Приложение просто незаметно выполняет свою работу: загружает каждую фотографию при любых проблемах с сетью или ограничениях операционной системы.

Такая невидимая надёжность — не магия. Она достигается за счёт четырёх независимых уровней защиты: ContentObserver обеспечивает скорость, MediaScanWorker — полноту, WorkManager — устойчивость к сбоям, а NetworkMonitor закрывает пограничные сценарии, о которых часто забывают.

Уберите любой из этих механизмов — и получите систему, которая работает в 95% случаев. Оставьте все четыре — и получите систему, которая работает в 99,9% случаев. Именно эти последние 4,9% отличают продукт, которому пользователи доверяют, от приложения, которое они однажды незаметно удалят.

Тот же принцип действует на каждом уровне системы. База данных Room нужна не потому, что приложению просто требуется база данных, а потому, что необходима запись, которая переживёт завершение процесса. Предварительно подписанные URL используются не потому, что это изящное решение, а потому, что API-сервер не должен становиться узким местом по пропускной способности. Загрузка частями нужна не только из-за требований S3, а потому, что мобильные сети нестабильны, и начинать загрузку файла размером 50 МБ с нуля после четвёртого сбоя — крайне плохой пользовательский опыт.

Каждое архитектурное решение в этой системе является прямым ответом на конкретный сценарий отказа. Именно так мыслит Staff-инженер: не «Что мне нужно построить?», а «Что может пойти не так и как сделать так, чтобы система не зависела от этого?».

Самые частые вопросы на собеседованиях по этой задаче

Вопрос: что произойдёт, если MediaStore дважды вызовет ContentObserver для одной фотографии?

Вызов enqueueUniqueWork с политикой ExistingWorkPolicy.KEEP сделает вторую постановку задачи в очередь незаметной пустой операцией. Проверка контрольной суммы SHA-256 в DAO не позволит создать дублирующую строку в базе данных.

Вопрос: почему бы не использовать FileObserver для папки DCIM вместо ContentObserver?

FileObserver отслеживает необработанные пути файловой системы. В Android 10 и новее этот подход перестаёт быть надёжным из-за Scoped Storage, поскольку приложение больше не может свободно обращаться к путям вида /sdcard/DCIM.

Кроме того, FileObserver не обнаружит фотографии, загруженные из WhatsApp, снимки экрана и другие изображения, сохранённые за пределами DCIM.

ContentObserver, зарегистрированный для MediaStore.Images.Media.EXTERNAL_CONTENT_URI, получает уведомления обо всех изображениях, проиндексированных операционной системой, независимо от их физического расположения.

Вопрос: как обрабатывать серийную съёмку, когда за две секунды создаются 30 фотографий?

Вопрос: что делать, если срок действия предварительно подписанного URL истёк во время загрузки?

Вопрос: как обрабатывать одновременную загрузку одной и той же фотографии с двух устройств?

Вопрос: как работать с фотографиями HEIC в старых версиях Android, которые не поддерживают этот формат?

Вопрос: какой сценарий приводит к наибольшему риску потери данных?

Вопрос: что произойдёт, если пользователь удалит фотографию до завершения её загрузки?

Вопрос: как предотвратить неограниченный рост очереди синхронизации?

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: