📷 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:


🎯 Objetivo do Projeto

Criar um visor de câmera ativa ocupando toda a tela com uma mira flutuante neon centralizada.

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


🛠️ 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.

  1. Abra o arquivo app > src > main > AndroidManifest.xml.
  2. 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:


🛠️ 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:


📖 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()
    }
}