Programação Por Equipe DevBras

Capa do artigo: Upload longo no Android: WorkManager que retoma

Upload longo no Android: WorkManager que retoma

Upload de centenas de fotos no Android que para no meio? Guarde o estado no Room, use id idempotente e WorkManager com dataSync, e teste Doze pelo adb.

Um vistoriador tira 300 fotos num galpão sem sinal, toca em "Enviar" e guarda o celular no bolso. Duas horas depois, metade das fotos chegou, o app sumiu da lista de recentes e ninguém sabe o que faltou. Não foi defeito do aparelho: no Android atual, um envio longo só termina se for desenhado para ser interrompido e retomado.

Este post mostra esse desenho com WorkManager: o estado de cada foto no banco local, um id gerado no aparelho para o servidor reconhecer repetições, o worker em primeiro plano com o tipo dataSync e os comandos adb para provocar as interrupções antes que o cliente as encontre. Se a dúvida ainda é qual API usar, comece por segundo plano no Android: WorkManager ou serviço.

O que pode interromper o envio

  • O sistema encerra o processo para liberar memória, ou a pessoa fecha o app pela lista de recentes.
  • O aparelho entra em Doze parado na mesa e a rede só volta nas janelas de manutenção.
  • A rede cai no meio de uma foto e a resposta do servidor nunca chega, embora o arquivo tenha chegado.
  • No Android 15, serviços do tipo dataSync têm 6 horas de execução a cada 24 horas. Passou disso, o sistema para o serviço.

Nenhuma dessas situações se resolve com um serviço que "não morre". O que resolve é saber, em disco, o que já foi e o que falta.

Cada foto com seu estado no Room

A lista de envio não pode viver numa variável. Cada foto vira uma linha com o caminho do arquivo, um id gerado no momento da captura e o estado:

@Entity(tableName = "foto_pendente")
data class FotoPendente(
    @PrimaryKey val id: String = UUID.randomUUID().toString(), // gerado na captura
    val vistoriaId: Long,
    val caminho: String,             // arquivo em filesDir, não no cache
    val estado: String = "PENDENTE", // PENDENTE, ENVIADA, ERRO_PERMANENTE
    val tentativas: Int = 0
)

@Dao
interface FotoDao {
    @Query("SELECT * FROM foto_pendente WHERE estado = 'PENDENTE' ORDER BY rowid LIMIT 1")
    suspend fun proxima(): FotoPendente?

    @Query("SELECT COUNT(*) FROM foto_pendente WHERE estado = 'PENDENTE'")
    suspend fun faltam(): Int

    @Query("UPDATE foto_pendente SET estado = :estado WHERE id = :id")
    suspend fun marcar(id: String, estado: String)
}

O arquivo fica em filesDir porque o cache pode ser limpo pelo sistema quando falta espaço. A diferença entre as pastas está em onde o app Android guarda os dados.

O servidor precisa aceitar a mesma foto duas vezes

O caso mais traiçoeiro é a foto que chegou ao servidor, mas cuja resposta se perdeu. O app vai mandar de novo. Se o servidor criar um registro por requisição, a vistoria fica com fotos duplicadas. Por isso o id nasce no aparelho e vai na URL de um PUT: repetir o mesmo PUT tem o mesmo efeito de mandar uma vez. É a mesma ideia da idempotência em pagamentos.

Um recebedor mínimo em Python, só com a biblioteca padrão, para testar o comportamento:

# recebe_fotos.py: recebedor idempotente de fotos (só biblioteca padrão)
import hashlib, json, os, sqlite3
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

PASTA = "fotos"
os.makedirs(PASTA, exist_ok=True)
db = sqlite3.connect("fotos.db", check_same_thread=False)
db.execute("CREATE TABLE IF NOT EXISTS foto (id TEXT PRIMARY KEY, vistoria TEXT, sha256 TEXT)")

class Recebe(BaseHTTPRequestHandler):
    def do_PUT(self):
        # PUT /vistorias/123/fotos/<id gerado no aparelho>
        partes = self.path.strip("/").split("/")
        if len(partes) != 4 or partes[0] != "vistorias" or partes[2] != "fotos":
            return self.responder(404, {"erro": "rota"})
        vistoria, foto_id = partes[1], partes[3]
        corpo = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        sha = hashlib.sha256(corpo).hexdigest()
        linha = db.execute("SELECT sha256 FROM foto WHERE id = ?", (foto_id,)).fetchone()
        if linha:  # repetição: o aparelho não recebeu a resposta da primeira vez
            if linha[0] != sha:
                return self.responder(409, {"erro": "mesmo id, conteúdo diferente"})
            return self.responder(200, {"id": foto_id, "repetida": True})
        with open(os.path.join(PASTA, foto_id + ".jpg"), "wb") as f:
            f.write(corpo)
        db.execute("INSERT INTO foto VALUES (?, ?, ?)", (foto_id, vistoria, sha))
        db.commit()
        self.responder(201, {"id": foto_id, "repetida": False})

    def responder(self, status, dados):
        b = json.dumps(dados).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(b)))
        self.end_headers()
        self.wfile.write(b)

ThreadingHTTPServer(("127.0.0.1", 8093), Recebe).serve_forever()

Rodando o script e mandando a mesma foto duas vezes e depois outro arquivo com o mesmo id, com curl -X PUT --data-binary @a.jpg http://127.0.0.1:8093/vistorias/123/fotos/0f9c2a7e-..., a saída foi:

{"id": "0f9c2a7e-5b1d-4c1e-9a77-3d2f1b6e8c40", "repetida": false} 201
{"id": "0f9c2a7e-5b1d-4c1e-9a77-3d2f1b6e8c40", "repetida": true} 200
{"erro": "mesmo id, conte\u00fado diferente"} 409

A primeira gravou (201), a repetição foi reconhecida (200, sem duplicar) e o conteúdo diferente com o mesmo id foi recusado (409), o que indica bug no app e não deve ser tentado de novo. Num servidor de produção, a mesma regra vale com o banco que você já usa: chave primária no id da foto.

O worker: uma foto por vez, em primeiro plano

O worker pega a próxima pendente, envia, marca e repete. Como o envio pode passar dos 10 minutos de um worker comum, ele chama setForeground(), que faz o WorkManager subir um serviço em primeiro plano com notificação, como descreve a página de workers de longa duração:

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

    private val dao = AppDatabase.get(ctx).fotoDao()
    private val api = VistoriaApi.create()

    override suspend fun doWork(): Result {
        setForeground(infoNotificacao(dao.faltam()))
        while (!isStopped) {
            val foto = dao.proxima() ?: return Result.success()
            val resposta = try {
                api.enviarFoto(foto.vistoriaId, foto.id, File(foto.caminho).asRequestBody())
            } catch (e: IOException) {
                return Result.retry()        // rede caiu: tenta depois, do ponto onde parou
            }
            when (resposta.code()) {
                200, 201 -> dao.marcar(foto.id, "ENVIADA")
                in 500..599 -> return Result.retry()
                else -> dao.marcar(foto.id, "ERRO_PERMANENTE") // 409 e outros 4xx
            }
            val faltam = dao.faltam()
            setProgress(workDataOf("faltam" to faltam))
            setForeground(infoNotificacao(faltam))
        }
        return Result.retry()                 // parado pelo sistema: o banco sabe o que falta
    }

    private fun infoNotificacao(faltam: Int): ForegroundInfo {
        val n = NotificationCompat.Builder(applicationContext, "envio")
            .setSmallIcon(R.drawable.ic_upload)
            .setContentTitle("Enviando fotos da vistoria")
            .setContentText("Faltam $faltam")
            .setOngoing(true)
            .build()
        return ForegroundInfo(42, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
    }
}

No manifesto, o tipo é declarado no serviço que o próprio WorkManager usa, junto com a permissão do tipo:

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

<service
    android:name="androidx.work.impl.foreground.SystemForegroundService"
    android:foregroundServiceType="dataSync"
    tools:node="merge" />

Se o limite de 6 horas do Android 15 for atingido, o worker é parado e o WorkManager registra o motivo STOP_REASON_FOREGROUND_SERVICE_TIMEOUT (adicionado na série 2.10, segundo as notas de versão). Como o estado está no banco, a próxima execução continua da foto seguinte.

Enfileirar sem duplicar

O botão "Enviar" pode ser tocado duas vezes, e o app pode enfileirar de novo ao abrir. Um trabalho único com nome evita dois workers disputando as mesmas fotos:

fun enfileirarEnvio(ctx: Context) {
    val pedido = OneTimeWorkRequestBuilder<EnviarFotosWorker>()
        .setConstraints(Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED).build())
        .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
        .build()
    WorkManager.getInstance(ctx)
        .enqueueUniqueWork("envio-fotos", ExistingWorkPolicy.KEEP, pedido)
}

A tela observa o mesmo nome com getWorkInfosForUniqueWorkFlow("envio-fotos") e mostra o "faltam" do progresso. Só diga "enviado" quando o banco disser que não falta nada, e não quando o worker começou; o porquê está em barra de upload não é sucesso.

Provoque as interrupções antes do cliente

Com o envio de umas 50 fotos rodando num aparelho de teste ligado por adb, passe por cada situação e confira se a contagem só desce e se nenhuma foto duplica no servidor:

# 1. Doze forçado (comandos da página de Doze e App Standby)
adb shell dumpsys battery unplug
adb shell dumpsys deviceidle force-idle
# ...espere, depois saia do Doze
adb shell dumpsys deviceidle unforce
adb shell dumpsys battery reset

# 2. App Standby
adb shell am set-inactive br.com.suaempresa.vistoria true
adb shell am set-inactive br.com.suaempresa.vistoria false

# 3. Processo morto no meio do envio
adb shell am kill br.com.suaempresa.vistoria

# 4. Estado interno do WorkManager no logcat
adb shell am broadcast -a "androidx.work.diagnostics.REQUEST_DIAGNOSTICS" -p "br.com.suaempresa.vistoria"

Teste também o modo avião no meio de uma foto grande, que é o caso da resposta perdida. Se tudo isso passar, o envio aguenta o galpão sem sinal. Os comandos de Doze e Standby vêm da documentação oficial, e o de diagnóstico, da página de depuração do WorkManager. Para escolher o aparelho de teste, veja celular Android para depurar.

O recebedor em Python foi executado e a saída acima é real. O código Kotlin segue a documentação citada, mas não foi compilado aqui, porque o ambiente usado para escrever não tem o SDK do Android. O cliente HTTP (VistoriaApi, no estilo Retrofit) fica a seu critério.

Avalie este artigo

Referência: http://www.klebermota.eti.br/2011/11/08/executando-um-servico-em-segundo-plano-no-android/