📷 P08: Batalha Scanner (QR Code Scanner com ML Kit)
Bem-vindo à Fase 5: Conectando com o Mundo Físico! 📷
Neste projeto de nível intermediário-avançado, vamos quebrar a barreira entre o software e o mundo real! Você aprenderá a interagir diretamente com o hardware de câmera do dispositivo utilizando a biblioteca oficial CameraX combinada com a biblioteca de Inteligência Artificial do Google, o ML Kit Barcode Scanning.
A meta é criar um leitor de QR Code integrado que processa imagens em tempo real para encontrar chaves secretas como POKE_BATTLE e iniciar lutas instantâneas contra chefes no seu jogo!
✅ Pré-requisitos
Antes de começar, certifique-se de já ter estudado:
- 📘 Cap 16: Captura QR: Scanner de Itens
- 🏗️ Projeto anterior: P07: Master Pokedex
🎯 Objetivo do Projeto
Criar um visor de câmera ativa ocupando toda a tela com uma mira flutuante neon centralizada.
- Permissões em Tempo de Execução: Solicitar a permissão de acesso à câmera de forma reativa e segura dentro da arquitetura do Compose.
- Detecção por Inteligência Artificial: Processar os frames de vídeo em tempo real (em segundo plano) para descriptografar QR Codes apontados.
- Mecânica de Jogo (Gatilho de Batalha): Se o QR Code contiver o texto
POKE_BATTLE, a interface inteira muda de cor (a mira vira vermelha piscante), exibe a mensagem “BATALHA INICIADA!” e revela um botão de ação especial na base da tela para “Fugir da Batalha”, resetando o leitor.
graph TD
A[Início / Abrir App] --> B{Possui Permissão da Câmera?}
B -- Não --> C[Solicitar permissão em tempo de execução]
C --> D{Permissão Concedida?}
D -- Sim --> E[Carregar Tela do Scanner]
D -- Não --> F[Exibir aviso de Permissão Necessária]
B -- Sim --> E
E --> G[CameraPreview - Inicializar CameraX]
G --> H[ImageAnalysis - ML Kit processa quadros em background]
H --> I{QR Code POKE_BATTLE detectado?}
I -- Não --> H
I -- Sim --> J[Gatilho de Batalha! Borda fica vermelha]
J --> K[Exibir botão FUGIR DA BATALHA]
K --> L[Clique em Fugir -> Resetar Estados]
L --> H
📖 Dicionário do Projeto
- CameraX: A biblioteca Jetpack moderna e simplificada do Google para gerenciar a captura de fotos e visualização de câmera, projetada para funcionar de forma estável em milhares de modelos de celulares diferentes.
- ProcessCameraProvider: O motor que vincula as saídas da câmera do aparelho (ex: o visor visível e o analisador de fotos) diretamente ao ciclo de vida da tela do aplicativo (
LifecycleOwner). - PreviewView: Uma visualização clássica otimizada que recebe os sinais digitais da câmera do celular e os desenha na tela de forma contínua.
- Google ML Kit Barcode Scanning: A poderosa biblioteca de machine learning offline do Google usada para escanear, identificar e descriptografar dezenas de tipos de códigos de barras e QR Codes na velocidade da luz.
- ImageAnalysis / ImageProxy: Componentes do CameraX. O
ImageAnalysisintercepta o fluxo de vídeo da câmera frame a frame, empacotando cada imagem em um objeto leveImageProxypara enviá-lo a threads secundárias de processamento sem congelar a tela. - rememberLauncherForActivityResult: A API declarativa do Compose para solicitar ações do sistema operacional Android (como pedir permissão de câmera ao usuário) e receber a resposta em variáveis reativas.
- AndroidView: Componente integrador especial do Compose usado para renderizar layouts clássicos do Android baseados em Views nativas legadas (como o
PreviewViewda câmera) dentro do Compose de forma direta.
🛠️ Passo 1: Solicitando Acesso à Câmera no Manifesto
Diferente de recursos locais, o uso da câmera do celular exige uma declaração explícita de privacidade no sistema operacional.
- Abra o arquivo
app > src > main > AndroidManifest.xml. - Adicione a linha de permissão logo acima da tag
<application>: ```xml
---
## 🛠️ Passo 2: Adicionando Dependências do CameraX e ML Kit (Gradle)
1. Abra `Gradle Scripts > build.gradle (Module :app)`.
2. No bloco `dependencies`, carregue os kits de câmera e leitura inteligente:
```gradle
dependencies {
// CameraX Core e Camera2
implementation "androidx.camera:camera-core:1.3.1"
implementation "androidx.camera:camera-camera2:1.3.1"
implementation "androidx.camera:camera-lifecycle:1.3.1"
implementation "androidx.camera:camera-view:1.3.1"
// Google ML Kit Barcode Scanning
implementation "com.google.mlkit:barcode-scanning:17.2.0"
...
}
Clique em Sync Now para aplicar.
🎨 Passo 3: Solicitando Permissão Reativa (Compose UI)
Em sistemas Android modernos, apenas colocar a permissão no Manifesto não basta; é obrigatório que o usuário dê permissão de forma explícita na hora em que o app abre.
Implementaremos o fluxo reativo na MainActivity.kt usando o contrato RequestPermission() integrado a um LaunchedEffect para solicitar acesso ao iniciar o app, conforme o Gabarito.
⚙️ Passo 4: Criando o Visor de Câmera (CameraPreview)
No Compose, utilizamos a tag AndroidView para montar e renderizar a PreviewView clássica do CameraX.
Dentro de sua inicialização, ativamos o ProcessCameraProvider para vincular o visor de visualização e configuramos um analisador de imagem em loop de segundo plano (ImageAnalysis.Builder) associado a uma Thread isolada (Executors.newSingleThreadExecutor()). A cada frame capturado, acionamos o analisador do ML Kit para descriptografar os dados da imagem em busca de strings, liberando a memória do frame logo em seguida.
🎨 Passo 5: Overlay da Mira e Gatilhos de Batalha
Crie um contêiner Box que sobrepõe a visualização da câmera e a mira central. A cor e o comportamento da borda da mira reagem dinamicamente à variável de estado isBattleStarted:
- Borda Verde Neon piscante quando o leitor está ativamente buscando um QR Code.
- Borda Vermelha vibrante com o texto “BATALHA INICIADA!” e um cartão inferior contendo a opção “FUGIR DA BATALHA” ao detectar o código chave.
🛠️ Requisitos Críticos de Configuração e Fechamento
1. Liberação de Memória (Memory Leak)
No analisador de imagens, o uso de imageProxy.close() dentro do bloco .addOnCompleteListener é obrigatório. Caso se esqueça de liberar a imagem após o processamento, o fluxo do CameraX travará na tela após analisar o primeiro frame, pois esgotará os buffers de hardware de câmera.
🏆 Desafios para você (Upgrade!)
Se você terminou de programar a funcionalidade base e tudo está funcionando, experimente estes upgrades:
- Histórico de Capturas: Crie uma
LazyColumnque exibe a lista dos QR codes já escaneados. - Vibração ao Detectar: Use
Vibrator/VibrationEffectpara vibrar o celular ao reconhecer um QR code.
📖 Gabarito Oficial de Código (Para Conferência)
Use o código Kotlin completo abaixo para estruturar a sua classe MainActivity.kt.
📄 Código de Lógica e Tela Completa (MainActivity.kt)
Disponível em: app/src/main/java/br/com/curso/battlescanner/MainActivity.kt
package br.com.curso.battlescanner
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage
import java.util.concurrent.Executors
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
BattleScannerTheme {
ScannerApp()
}
}
}
}
/**
* Tema gamer escuro para o visor
*/
@Composable
fun BattleScannerTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = darkColorScheme(
primary = Color(0xFF00E676), // Verde Neon
secondary = Color(0xFF00B0FF),
background = Color(0xFF0A0A0A)
),
content = content
)
}
/**
* Gerenciador reativo de permissão
*/
@Composable
fun ScannerApp() {
val context = LocalContext.current
var hasCameraPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
)
}
// Registra callback de resposta da permissão
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted ->
hasCameraPermission = granted
}
)
// Lança a solicitação ao iniciar
LaunchedEffect(key1 = true) {
if (!hasCameraPermission) {
launcher.launch(Manifest.permission.CAMERA)
}
}
if (hasCameraPermission) {
ScannerScreen()
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Permissão de Câmera Necessária para Jogar",
color = Color.White,
textAlign = TextAlign.Center
)
}
}
}
/**
* Tela do Scanner e Painéis de Estado
*/
@Composable
fun ScannerScreen() {
var scanResult by remember { mutableStateOf("Aponte para um QR Code") }
var isBattleStarted by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize()) {
// Renderizador físico do visor da câmera
CameraPreview(onBarcodeDetected = { code ->
scanResult = code
if (code == "POKE_BATTLE") {
isBattleStarted = true
}
})
// Mira Flutuante do Leitor (Muda de cor conforme o estado do leitor)
Box(
modifier = Modifier
.fillMaxSize()
.padding(60.dp)
.border(2.dp, if (isBattleStarted) Color.Red else Color(0xFF00E676), RoundedCornerShape(24.dp)),
contentAlignment = Alignment.Center
) {
if (isBattleStarted) {
Text(
"BATALHA INICIADA!",
color = Color.Red,
fontWeight = FontWeight.Black,
fontSize = 24.sp,
textAlign = TextAlign.Center
)
} else {
Text(
"SCANNER ATIVO",
color = Color(0xFF00E676),
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
}
}
// Painel Inferior de Informação e Ações
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.7f))
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("STATUS DO SCANNER", fontSize = 12.sp, color = Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = scanResult,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = if (isBattleStarted) Color.Red else Color.White,
textAlign = TextAlign.Center
)
// Exibe botão de fuga caso a batalha tenha sido acionada
if (isBattleStarted) {
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
isBattleStarted = false
scanResult = "Aponte para um QR Code"
},
colors = ButtonDefaults.buttonColors(containerColor = Color.Red)
) {
Text("FUGIR DA BATALHA", color = Color.White)
}
}
}
}
}
/**
* Inicialização e Acoplamento da CâmeraX
*/
@Composable
fun CameraPreview(onBarcodeDetected: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
AndroidView(
factory = { ctx ->
val previewView = PreviewView(ctx)
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
// 1. Configura Caso de Uso de Visualização
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// 2. Configura Analisador Inteligente de Imagem (ML Kit)
val barcodeScanner = BarcodeScanning.getClient()
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
it.setAnalyzer(cameraExecutor) { imageProxy ->
processImageProxy(barcodeScanner, imageProxy, onBarcodeDetected)
}
}
// 3. Escolhe Câmera Traseira Padrão
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
cameraProvider.unbindAll() // Reseta vinculações anteriores
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageAnalysis
)
} catch (exc: Exception) {
Log.e("CameraPreview", "Use case binding failed", exc)
}
}, ContextCompat.getMainExecutor(ctx))
previewView
},
modifier = Modifier.fillMaxSize()
)
}
/**
* Função Auxiliar de Descriptografia e Processamento do Frame
*/
@androidx.annotation.OptIn(ExperimentalGetImage::class)
private fun processImageProxy(
barcodeScanner: com.google.mlkit.vision.barcode.BarcodeScanner,
imageProxy: ImageProxy,
onBarcodeDetected: (String) -> Unit
) {
val mediaImage = imageProxy.image
if (mediaImage != null) {
// Converte o Frame do CameraX no formato esperado pelo ML Kit
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
barcodeScanner.process(image)
.addOnSuccessListener { barcodes ->
for (barcode in barcodes) {
val rawValue = barcode.rawValue
if (rawValue != null) {
onBarcodeDetected(rawValue)
}
}
}
.addOnFailureListener {
Log.e("QRAnalyzer", "Scan failed", it)
}
.addOnCompleteListener {
// MUITO CRÍTICO: Libera o proxy de frame de vídeo para permitir nova captura!
imageProxy.close()
}
} else {
imageProxy.close()
}
}