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
dataSynctê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.
Referência: http://www.klebermota.eti.br/2011/11/08/executando-um-servico-em-segundo-plano-no-android/