Files
carinha-control-panel/android/app/java/br/carinha/rosto/MainActivity.java
T
julioandClaude Opus 5.5 b6b8e2efd5 fix(android): le o sinal do Wi-Fi sem depender do ID da rede
No Android 10+ sem localizacao o networkId vem -1, mas o RSSI e valido.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:57:56 -03:00

1141 lines
53 KiB
Java

package br.carinha.rosto;
import android.Manifest;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.BatteryManager;
import android.content.SharedPreferences;
import android.net.Uri;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.hardware.camera2.CameraAccessException;
import android.hardware.camera2.CameraCaptureSession;
import android.hardware.camera2.CameraCharacteristics;
import android.hardware.camera2.CameraDevice;
import android.hardware.camera2.CameraManager;
import android.hardware.camera2.CaptureRequest;
import android.hardware.camera2.params.StreamConfigurationMap;
import android.media.AudioFormat;
import android.media.AudioRecord;
import android.media.Image;
import android.media.ImageReader;
import android.media.MediaRecorder;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.text.InputType;
import android.util.Log;
import android.util.Size;
import android.view.View;
import android.view.WindowManager;
import android.webkit.ConsoleMessage;
import android.webkit.PermissionRequest;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.webkit.ValueCallback;
import android.webkit.JavascriptInterface;
import android.widget.FrameLayout;
import org.json.JSONObject;
import android.widget.EditText;
import android.widget.Toast;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Carinha — mostra o rosto (HTML/SVG) em tela cheia, com a tela sempre ligada.
*
* A pagina vem do servidor da carinha (o mesmo kiosk-server.py da placa), entao
* trocar o rosto ou mandar um gesto pela rede vale na hora para este celular.
* Se o servidor nao responder, cai para a copia local embutida no APK (rosto
* funcionando, sem controle remoto).
*
* Segure o dedo na tela para trocar o endereco do servidor.
*
* ---------------------------------------------------------------------------
* PONTE DO MICROFONE
* O Java le o nivel do microfone (AudioRecord) e manda para a pagina chamando
* window.eilik.som(nivel), com nivel de 0 (silencio) a 1 (bem alto). Quem decide
* o que fazer com isso e a propria pagina: barulho alto e repentino vira
* "surpreso" e silencio prolongado faz dormir. Assim a mesma pagina continua
* funcionando sozinha na telinha do Pi (sem microfone), sem nenhuma mudanca la.
*
* ---------------------------------------------------------------------------
* PONTE DA PRESENCA (movimento)
* O Java abre a camera FRONTAL (Camera2), compara a luminancia de um quadro com
* a do anterior e manda o resultado para a pagina chamando
* window.eilik.movimento(vel, cx, cy), com vel de 0 (parado) a 1 (muito
* movimento) e cx,cy o centro do que se mexeu (0 a 1 na imagem). Assim a carinha
* acorda quando algo se mexe na frente dela e os olhos acompanham o movimento.
* Isto funciona mesmo com o rosto vindo do servidor por http, onde o navegador
* nao deixaria a pagina usar a camera (contexto inseguro).
*/
public class MainActivity extends Activity {
private static final String PREFS = "carinha";
private static final String CHAVE = "servidor";
private static final String CHAVE_TOKEN = "token";
private static final String PADRAO = "http://192.168.1.15:8100";
private static final String TAG = "CarinhaMic";
private static final String TAG_JS = "CarinhaJS"; // console.log da pagina
private static final int PEDIDO_PERMISSOES = 1001;
private static final String PAGINA_CAMERA = "file:///android_asset/camera.html";
private static final int TAXA_MIC = 16000; // Hz — de sobra para medir volume
private static final long INTERVALO_ENVIO = 50; // ms entre envios a pagina (~20/s)
private static final String TAG_MOV = "CarinhaMov"; // movimento da camera frontal
private static final String CHAVE_MOV = "movimento"; // liga/desliga nas preferencias
private static final long INTERVALO_MOV = 33; // ms entre envios (~30/s)
private static final int LIMIAR_MOV = 26; // salto de luminancia (igual a pagina)
private static final int AMOSTRAS_MOV = 30000; // maximo de pixels analisados por quadro
private WebView web;
private String servidor;
private String token = ""; // CARINHA_TOKEN do servidor; vazio = servidor aberto
private final CerebroLocal cerebroLocal = new CerebroLocal();
private boolean jaCaiuParaLocal = false;
private boolean jaRecarregou = false;
private boolean usandoLocal = false; // servidor caiu: ficamos na copia do APK
private boolean naCamera = false; // a pagina aberta agora e a da camera
// --- microfone ---
private AudioRecord gravador;
private volatile boolean pararMic;
private Thread fioMic;
// --- presenca (movimento pela camera frontal) ---
private boolean movimentoLigado = true; // reagir ao movimento (preferencia)
private volatile boolean querMovimento; // a captura deve estar ativa
private boolean abrindoCamara; // openCamera ja foi pedido e ainda nao respondeu
private CameraManager camMan;
private CameraDevice camara;
private CameraCaptureSession sessao;
private ImageReader leitor;
private HandlerThread fioCam;
private Handler manipCam;
private int[] lumaAnt; // luminancia do quadro anterior
private int[] lumaAtual;
private int quadrosMov; // quadros ja analisados (aquecimento)
private long ultimoEnvioMov;
private long proximoLogMov;
private ControleRemoto remoto;
private volatile boolean emPrimeiroPlano;
private WebView rtcWeb;
private volatile boolean rtcPronto;
private boolean rtcAtivo;
private int rotacaoPreview;
private volatile long ultimoUsoTela;
private volatile boolean dormindo;
private String ladoDetector = "user";
private String ajustesPreview = "";
private String urlCamera() {
JSONObject c = remoto.config();
return PAGINA_CAMERA + "?lado=" + c.optString("cameraLado", "user") +
"&resolucao=" + c.optString("videoResolucao", "auto") + "&fps=" + c.optInt("videoFPS", 0);
}
private boolean micPermitido() {
JSONObject c = remoto.config();
return Sensores.ativo(c.optBoolean("sensorMic", true), c.optBoolean("micSono", true), dormindo);
}
private boolean cameraPermitida() {
JSONObject c = remoto.config();
return Sensores.ativo(c.optBoolean("sensorCamera", true), c.optBoolean("cameraSono", false), dormindo);
}
private void atualizarSensores() {
if (!emPrimeiroPlano) return;
if (micPermitido()) iniciarCaptura(); else pararCaptura();
if (!cameraPermitida()) {
if (naCamera) abrirRosto();
pararMovimento();
rtcWeb.evaluateJavascript("window.rtcParar&&window.rtcParar()", null);
rtcAtivo = false;
} else if (naCamera) {
String url = urlCamera();
if (!url.equals(ajustesPreview)) { ajustesPreview = url; web.loadUrl(url); }
} else {
String lado = remoto.config().optString("cameraLado", "user");
if (!lado.equals(ladoDetector)) { pararMovimento(); ladoDetector = lado; }
iniciarMovimento();
}
web.evaluateJavascript("window.carinhaSensores=" +
"{microfone:" + (micPermitido() && gravador != null) + ",camera:" +
(cameraPermitida() && (camara != null || rtcAtivo)) + "}", null);
}
@Override
protected void onCreate(Bundle estado) {
super.onCreate(estado);
// tela sempre ligada: isto e um enfeite de mesa, nao pode apagar
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
SharedPreferences p = getSharedPreferences(PREFS, MODE_PRIVATE);
servidor = p.getString(CHAVE, PADRAO);
token = p.getString(CHAVE_TOKEN, "");
movimentoLigado = p.getBoolean(CHAVE_MOV, true);
usarServidorDoPedido(getIntent()); // permite apontar o servidor ao lancar
cerebroLocal.chave = token;
cerebroLocal.iniciar(this, servidor);
remoto = new ControleRemoto(new Runnable() {
@Override public void run() {
runOnUiThread(new Runnable() {
@Override public void run() {
if (emPrimeiroPlano) {
if (!remoto.videoRTC() && rtcAtivo) {
rtcWeb.evaluateJavascript("window.rtcParar&&window.rtcParar()", null);
rtcAtivo = false;
}
atualizarSensores();
atualizarBrilho();
}
}
});
}
});
remoto.rtcListener(new ControleRemoto.RTCListener() {
@Override public boolean receber(final JSONObject pacote) {
if (!rtcPronto || !emPrimeiroPlano || naCamera) return false;
runOnUiThread(new Runnable() {
@Override public void run() {
if (!emPrimeiroPlano || naCamera) return;
boolean ativa = !pacote.isNull("id") && remoto.preview() && cameraPermitida();
if (ativa && !rtcAtivo) {
rtcAtivo = true;
pararMovimento();
} else if (!ativa && rtcAtivo) {
rtcWeb.evaluateJavascript("window.rtcParar&&window.rtcParar()", null);
rtcAtivo = false;
web.postDelayed(new Runnable() {
@Override public void run() { if (emPrimeiroPlano && !rtcAtivo && !naCamera) iniciarMovimento(); }
}, 600);
}
if (cameraPermitida()) {
try { pacote.put("config", remoto.config());
pacote.put("movimento", movimentoLigado && remoto.config().optBoolean("reagir", true));
} catch (Exception e) { /* JSON */ }
rtcWeb.evaluateJavascript("window.rtcReceber&&window.rtcReceber(" + pacote.toString() + ")", null);
}
}
});
return true;
}
});
web = new WebView(this);
web.setBackgroundColor(Color.BLACK);
WebSettings cfg = web.getSettings();
cfg.setJavaScriptEnabled(true);
cfg.setDomStorageEnabled(true);
cfg.setUseWideViewPort(true);
cfg.setLoadWithOverviewMode(true);
cfg.setMediaPlaybackRequiresUserGesture(false);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
cfg.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
}
web.setWebViewClient(new WebViewClient() {
@Override
public void onReceivedError(WebView vista, WebResourceRequest pedido,
WebResourceError erro) {
// so a pagina principal importa aqui: um 404 de favicon, por
// exemplo, nao pode derrubar para a copia local
if (pedido.isForMainFrame()) cairParaLocal(vista);
}
@SuppressWarnings("deprecation")
@Override
public void onReceivedError(WebView vista, int codigo, String descricao, String url) {
// API 21/22 nao recebem o pedido, entao so aqui vale o aviso antigo
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) cairParaLocal(vista);
}
});
// o console.log da pagina aparece no logcat (ajuda a ver a carinha reagindo)
web.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onConsoleMessage(ConsoleMessage m) {
Log.i(TAG_JS, m.message() + " (" + m.sourceId() + ":" + m.lineNumber() + ")");
return true;
}
@Override
public void onPermissionRequest(final PermissionRequest pedido) {
// a pagina da camera (getUserMedia) pede permissao ao WebView;
// concedemos so o que o app realmente ja tem
runOnUiThread(new Runnable() {
@Override
public void run() {
boolean pode = origemConfiavel(pedido.getOrigin());
for (String recurso : pedido.getResources()) {
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(recurso)
&& checkSelfPermission(Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED) pode = false;
if (PermissionRequest.RESOURCE_AUDIO_CAPTURE.equals(recurso)
&& checkSelfPermission(Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED) pode = false;
}
if (pode) {
pedido.grant(pedido.getResources());
Log.i(TAG, "camera/audio concedidos a pagina");
} else {
pedido.deny();
Log.i(TAG, "pagina pediu permissao que o app nao tem");
}
}
});
}
});
// segurar o dedo abre o menu (camera / servidor)
web.setOnLongClickListener(new View.OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
menu();
return true;
}
});
rtcWeb = new WebView(this);
rtcWeb.getSettings().setJavaScriptEnabled(true);
rtcWeb.getSettings().setMediaPlaybackRequiresUserGesture(false);
rtcWeb.addJavascriptInterface(new PonteRTC(), "CarinhaRTC");
rtcWeb.setWebViewClient(new WebViewClient() {
@Override public void onPageFinished(WebView view, String url) { rtcPronto = true; }
@Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { return true; }
});
rtcWeb.setWebChromeClient(new WebChromeClient() {
@Override public boolean onConsoleMessage(ConsoleMessage msg) {
Log.i("CarinhaRTC", msg.message()); return true;
}
@Override public void onPermissionRequest(final PermissionRequest request) {
runOnUiThread(new Runnable() {
@Override public void run() {
if (Build.VERSION.SDK_INT < 23 || checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
request.grant(new String[]{PermissionRequest.RESOURCE_VIDEO_CAPTURE});
else request.deny();
}
});
}
});
FrameLayout root = new FrameLayout(this);
root.addView(rtcWeb, new FrameLayout.LayoutParams(1, 1));
root.addView(web, new FrameLayout.LayoutParams(-1, -1));
setContentView(root);
rtcWeb.loadUrl("file:///android_asset/transmissao.html");
abrirRosto();
telaCheia();
pedirPermissoes();
}
/**
* Permite escolher o servidor na hora de iniciar o app, sem passar pelo menu:
*
* adb shell am start -n br.carinha.rosto/.MainActivity \
* -e servidor http://192.168.1.15:8099
*
* Serve para hospedar o servidor em outra maquina (o PC, por exemplo) e
* testar sem reconstruir o APK. O endereco fica guardado para as proximas vezes.
*/
private void usarServidorDoPedido(Intent it) {
if (it == null) return;
String novo = it.getStringExtra("servidor");
if (novo == null) return;
String tokenPedido = it.getStringExtra("token");
if (!token.isEmpty() && !token.equals(tokenPedido)) {
Log.w(TAG, "pedido para trocar o servidor sem o token certo: ignorado");
return;
}
if (token.isEmpty() && tokenPedido != null && !tokenPedido.trim().isEmpty()) {
token = tokenPedido.trim();
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString(CHAVE_TOKEN, token).apply();
}
novo = novo.trim();
while (novo.endsWith("/")) novo = novo.substring(0, novo.length() - 1);
if (novo.length() == 0) return;
servidor = novo;
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString(CHAVE, servidor).apply();
jaCaiuParaLocal = false;
jaRecarregou = false;
usandoLocal = false;
Log.i(TAG, "servidor escolhido ao iniciar: " + servidor);
}
/** Retomado em primeiro plano (singleTask): aceita um servidor novo no pedido. */
@Override
protected void onNewIntent(Intent it) {
super.onNewIntent(it);
setIntent(it);
if (it != null && it.getStringExtra("servidor") != null) {
usarServidorDoPedido(it);
abrirRosto();
}
}
/** Volta a mostrar a carinha (a pagina do servidor, ou a copia local). */
private void abrirRosto() {
naCamera = false;
remoto.servidor(servidor);
remoto.token(token);
if (usandoLocal) web.loadUrl("file:///android_asset/carinha.html");
else web.loadUrl(servidor + "/carinha.html?aparelho=1" + (token.isEmpty() ? "" : "&t=" + Uri.encode(token)));
iniciarMovimento(); // volta a observar quem passa na frente
}
/**
* Mostra a pagina da camera. Ela vem SEMPRE do arquivo embutido no APK
* (file://), porque so um contexto seguro deixa a pagina pedir a camera —
* servida por http, o navegador bloquearia o getUserMedia.
*/
private void abrirCamera() {
if (!cameraPermitida()) { aviso("Camera desativada nos ajustes de sensores"); return; }
naCamera = true;
rtcWeb.evaluateJavascript("window.rtcParar&&window.rtcParar()", null);
rtcAtivo = false;
pararMovimento(); // a pagina da camera vai usar a camera: solta a nossa
ajustesPreview = urlCamera(); web.loadUrl(ajustesPreview);
}
/** Menu do toque longo. */
private void menu() {
final String[] opcoes = {"Ver a camera", "Voltar para a carinha",
(movimentoLigado ? "Desligar reacao ao movimento" : "Ligar reacao ao movimento"),
"Mudar o servidor", "Sensores acordado / dormindo", "Camera e qualidade do video", "Token do servidor"};
new AlertDialog.Builder(this)
.setTitle("Carinha")
.setItems(opcoes, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface d, int i) {
if (i == 0) abrirCamera();
else if (i == 1) abrirRosto();
else if (i == 2) alternarMovimento();
else if (i == 3) pedirServidor();
else if (i == 4) menuSensores();
else if (i == 5) menuVideo();
else pedirToken();
}
})
.show();
}
/** Liga/desliga a reacao ao movimento (guardado nas preferencias). */
private void salvarAjuste(String chave, Object valor) {
try { JSONObject c = new JSONObject(); c.put(chave, valor); remoto.configurar(c);
aviso("Ajuste enviado ao PC"); }
catch (Exception e) { aviso("Falha ao alterar ajuste"); }
}
private void menuSensores() {
final String[] chaves = {"sensorMic", "sensorCamera", "micSono", "cameraSono"};
final boolean[] valores = {remoto.config().optBoolean(chaves[0], true),
remoto.config().optBoolean(chaves[1], true), remoto.config().optBoolean(chaves[2], true),
remoto.config().optBoolean(chaves[3], false)};
new AlertDialog.Builder(this).setTitle("Sensores")
.setMultiChoiceItems(new String[]{"Microfone ligado", "Camera ligada", "Permitir microfone durante sono", "Permitir camera durante sono"}, valores,
new DialogInterface.OnMultiChoiceClickListener() {
@Override public void onClick(DialogInterface d, int i, boolean valor) { valores[i] = valor; }
})
.setPositiveButton("Salvar", new DialogInterface.OnClickListener() {
@Override public void onClick(DialogInterface d, int w) {
try { JSONObject c = new JSONObject(); for (int i=0;i<4;i++) c.put(chaves[i], valores[i]);
remoto.configurar(c); aviso("Ajustes enviados ao PC"); } catch (Exception e) {}
}
}).setNegativeButton("Cancelar", null).show();
}
private void menuVideo() {
new AlertDialog.Builder(this).setTitle("Camera e video")
.setItems(new String[]{"Camera frontal / traseira", "Resolucao", "Quadros por segundo"}, new DialogInterface.OnClickListener() {
@Override public void onClick(DialogInterface d, final int i) {
final String[] textos = i == 0 ? new String[]{"Frontal", "Traseira"} : i == 1 ?
new String[]{"Automatica (padrao)", "Maxima disponivel", "480p", "720p", "1080p", "4K", "8K"} :
new String[]{"Automatico (padrao)", "Maximo disponivel", "15", "24", "30", "60", "120", "240"};
final String[] valores = i == 0 ? new String[]{"user", "environment"} : i == 1 ?
new String[]{"auto", "max", "480", "720", "1080", "2160", "4320"} :
new String[]{"0", "-1", "15", "24", "30", "60", "120", "240"};
final String chave = i == 0 ? "cameraLado" : i == 1 ? "videoResolucao" : "videoFPS";
int selecionado = 0;
for (int n=0;n<valores.length;n++) if (valores[n].equals(remoto.config().optString(chave, valores[0]))) selecionado=n;
new AlertDialog.Builder(MainActivity.this).setTitle(i == 0 ? "Camera" : "Qualidade solicitada")
.setSingleChoiceItems(textos, selecionado, new DialogInterface.OnClickListener() {
@Override public void onClick(DialogInterface dialog, int n) {
salvarAjuste(chave, i == 2 ? Integer.valueOf(valores[n]) : valores[n]); dialog.dismiss();
}
}).setNegativeButton("Cancelar", null).show();
}
}).show();
}
private void alternarMovimento() {
movimentoLigado = !movimentoLigado;
getSharedPreferences(PREFS, MODE_PRIVATE).edit()
.putBoolean(CHAVE_MOV, movimentoLigado).apply();
if (movimentoLigado) { iniciarMovimento(); aviso("reagindo ao movimento"); }
else { pararMovimento(); aviso("sem reacao ao movimento"); }
}
private void telaCheia() {
View v = getWindow().getDecorView();
v.setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
private void aviso(String texto) {
Toast.makeText(this, texto, Toast.LENGTH_LONG).show();
}
/** Servidor fora do ar: mostra a carinha embutida no APK (sem controle remoto). */
private void cairParaLocal(WebView vista) {
if (jaCaiuParaLocal) return;
jaCaiuParaLocal = true;
usandoLocal = true;
naCamera = false;
aviso("sem servidor " + servidor + " — usando o rosto local");
vista.loadUrl("file:///android_asset/carinha.html");
}
private void pedirServidor() {
final EditText campo = new EditText(this);
campo.setInputType(InputType.TYPE_TEXT_VARIATION_URI);
campo.setText(servidor);
new AlertDialog.Builder(this)
.setTitle("Servidor da carinha")
.setMessage("Endereco do PC (ex.: http://192.168.1.15:8100)")
.setView(campo)
.setPositiveButton("Salvar", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface d, int w) {
String novo = campo.getText().toString().trim();
while (novo.endsWith("/")) novo = novo.substring(0, novo.length() - 1);
if (novo.length() == 0) return;
servidor = novo;
getSharedPreferences(PREFS, MODE_PRIVATE).edit()
.putString(CHAVE, servidor).apply();
jaCaiuParaLocal = false;
jaRecarregou = false;
usandoLocal = false; // endereco novo: tenta o servidor
abrirRosto();
}
})
.setNegativeButton("Cancelar", null)
.show();
}
/** Token do servidor (CARINHA_TOKEN da placa). Vazio = servidor sem autenticacao. */
private void pedirToken() {
final EditText campo = new EditText(this);
campo.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
campo.setText(token);
new AlertDialog.Builder(this)
.setTitle("Token do servidor")
.setMessage("O mesmo CARINHA_TOKEN configurado na placa")
.setView(campo)
.setPositiveButton("Salvar", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface d, int w) {
token = campo.getText().toString().trim();
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString(CHAVE_TOKEN, token).apply();
cerebroLocal.chave = token;
aviso(token.isEmpty() ? "token removido" : "token salvo");
abrirRosto();
}
})
.setNegativeButton("Cancelar", null)
.show();
}
/** So o servidor configurado (ou a copia local do APK) pode usar camera/microfone. */
private boolean origemConfiavel(Uri origem) {
if (origem == null) return false;
String o = origem.toString();
while (o.endsWith("/")) o = o.substring(0, o.length() - 1);
return o.startsWith("file:") || o.equals(servidor);
}
/** Voltar nao sai do app (num enfeite de mesa isso seria chato): volta a carinha. */
@Override
public void onBackPressed() {
abrirRosto();
}
// =====================================================================
// Microfone
// =====================================================================
/** Pede as permissoes que faltam (microfone e camera) e abre o microfone. */
private void pedirPermissoes() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
iniciarCaptura();
return;
}
List<String> faltando = new ArrayList<String>();
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED)
faltando.add(Manifest.permission.RECORD_AUDIO);
if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED)
faltando.add(Manifest.permission.CAMERA);
if (faltando.isEmpty()) { iniciarCaptura(); return; }
requestPermissions(faltando.toArray(new String[0]), PEDIDO_PERMISSOES);
}
@Override
public void onRequestPermissionsResult(int pedido, String[] permissoes, int[] resultado) {
super.onRequestPermissionsResult(pedido, permissoes, resultado);
if (pedido != PEDIDO_PERMISSOES) return;
boolean temMic = false, temCamera = false;
for (int i = 0; i < permissoes.length && i < resultado.length; i++) {
boolean ok = resultado[i] == PackageManager.PERMISSION_GRANTED;
if (Manifest.permission.RECORD_AUDIO.equals(permissoes[i])) temMic = ok;
if (Manifest.permission.CAMERA.equals(permissoes[i])) temCamera = ok;
}
if (temMic) iniciarCaptura();
else Log.i(TAG, "permissao do microfone negada — a carinha segue sem ouvir");
if (temCamera) iniciarMovimento();
else Log.i(TAG, "permissao da camera negada — a pagina da camera nao vai abrir");
}
/** Abre o AudioRecord e dispara a thread que mede o volume. */
private void iniciarCaptura() {
if (!emPrimeiroPlano || !micPermitido()) return;
if (Build.VERSION.SDK_INT >= 23 && checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) return;
if (fioMic != null && fioMic.isAlive()) return;
int minimo = AudioRecord.getMinBufferSize(TAXA_MIC,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);
if (minimo <= 0) minimo = TAXA_MIC * 2;
try {
gravador = new AudioRecord(MediaRecorder.AudioSource.MIC, TAXA_MIC,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
Math.max(minimo, TAXA_MIC));
if (gravador.getState() != AudioRecord.STATE_INITIALIZED) {
Log.w(TAG, "AudioRecord nao inicializou");
gravador.release();
gravador = null;
return;
}
gravador.startRecording();
} catch (Exception e) {
Log.w(TAG, "falha ao abrir o microfone: " + e);
if (gravador != null) { gravador.release(); gravador = null; }
return;
}
Log.i(TAG, "microfone ligado — " + TAXA_MIC + " Hz mono");
pararMic = false;
final AudioRecord rec = gravador;
fioMic = new Thread(new Runnable() {
@Override
public void run() {
short[] buf = new short[TAXA_MIC / 50]; // ~20 ms
long ultimoEnvio = 0;
double envelope = 0; // pico com decaimento
long proximoResumo = 0;
double picoJanela = 0;
while (!pararMic) {
int lidos;
try {
lidos = rec.read(buf, 0, buf.length);
} catch (Exception e) { break; }
if (lidos <= 0) continue;
double soma = 0;
for (int i = 0; i < lidos; i++) {
double s = buf[i];
soma += s * s;
}
double rms = Math.sqrt(soma / lidos);
double nivel = nivelDe(rms);
// sobe na hora e cai devagar: assim uma palma aparece bem
envelope = Math.max(nivel, envelope * 0.80);
long agora = System.currentTimeMillis();
if (agora - ultimoEnvio >= INTERVALO_ENVIO) {
ultimoEnvio = agora;
enviarNivel(arredondar(envelope));
}
if (envelope > picoJanela) picoJanela = envelope;
if (agora >= proximoResumo) { // uma linha por segundo
proximoResumo = agora + 1000;
Log.i(TAG, "nivel=" + String.format(java.util.Locale.US, "%.3f", envelope)
+ " pico=" + String.format(java.util.Locale.US, "%.3f", picoJanela));
picoJanela = 0;
}
}
}
}, "mic");
fioMic.start();
}
/** Fecha o AudioRecord e encerra a thread. */
private void pararCaptura() {
pararMic = true;
if (gravador != null) { try { gravador.stop(); } catch (Exception e) {} }
if (fioMic != null) {
try { fioMic.join(400); } catch (InterruptedException e) { /* segue */ }
fioMic = null;
}
if (gravador != null) {
try { gravador.stop(); } catch (Exception e) { /* ja parado */ }
gravador.release();
gravador = null;
Log.i(TAG, "microfone desligado");
}
}
/**
* Converte o RMS (0..32768) numa escala de 0 a 1 que a pagina entende.
* Trabalha em dBFS: numa sala silenciosa fica por volta de 0,2; fala comum
* passa de 0,6; palma/barulho alto chega perto de 1.
*/
private static double nivelDe(double rms) {
if (rms < 1.0) return 0.0;
double db = 20.0 * Math.log10(rms / 32768.0); // tipicamente -90..0
double n = (db + 55.0) / 50.0; // -55 dB -> 0 ; -5 dB -> 1
if (n < 0) n = 0;
if (n > 1) n = 1;
return n;
}
private static double arredondar(double v) {
return Math.round(v * 1000.0) / 1000.0;
}
/** Manda o nivel para a pagina, que decide o que fazer (gesto/ dormir). */
private void enviarNivel(final double nivel) {
if (!micPermitido() || !emPrimeiroPlano) return;
if (nivel >= remoto.somlim()) ultimoUsoTela = System.currentTimeMillis();
runOnUiThread(new Runnable() {
@Override
public void run() {
try {
web.evaluateJavascript(
"window.eilik&&window.eilik.som(" + nivel + ")", null);
} catch (Exception e) { /* pagina ainda nao pronta */ }
}
});
}
@Override
protected void onResume() {
super.onResume();
emPrimeiroPlano = true;
ultimoUsoTela = System.currentTimeMillis();
remoto.servidor(servidor);
remoto.iniciar();
web.onResume();
rtcWeb.onResume();
web.evaluateJavascript("window.carinhaAtiva=true", null);
telaCheia();
// voltou do segundo plano: atualiza o rosto — mas sem expulsar quem
// estava vendo a pagina da camera
if (jaRecarregou && !naCamera) {
abrirRosto();
}
jaRecarregou = true;
pedirPermissoes(); // religa o microfone (onPause desligou)
if (!naCamera) iniciarMovimento(); // e volta a enxergar o movimento
}
@Override
protected void onPause() {
super.onPause();
emPrimeiroPlano = false;
remoto.parar();
rtcWeb.evaluateJavascript("window.rtcParar&&window.rtcParar()", null);
rtcAtivo = false;
rtcWeb.onPause();
web.evaluateJavascript("window.carinhaAtiva=false", null);
web.onPause();
WindowManager.LayoutParams attr = getWindow().getAttributes();
attr.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE;
getWindow().setAttributes(attr);
pararCaptura(); // em segundo plano nao precisa ouvir
pararMovimento(); // nem observar o movimento
}
@Override
protected void onDestroy() {
cerebroLocal.fechar();
super.onDestroy();
remoto.parar();
pararCaptura();
pararMovimento();
rtcWeb.destroy();
}
// =====================================================================
// Presenca (movimento pela camera frontal)
// =====================================================================
/* Abre a camera FRONTAL com Camera2 num tamanho pequeno, mede o movimento
comparando a luminancia de um quadro com a do anterior (mesma ideia da
pagina camera.html) e conta para a pagina chamando
window.eilik.movimento(vel,cx,cy). Fica desligado enquanto a pagina da
camera esta aberta, para nao disputar a camera com o getUserMedia dela. */
/** (Re)abre a camera frontal para detectar movimento, se for para reagir. */
private void iniciarMovimento() {
if (!emPrimeiroPlano || !cameraPermitida()) return;
if (rtcAtivo) return;
querMovimento = true;
if ((!movimentoLigado && !remoto.preview()) || naCamera) return;
if (camara != null || abrindoCamara) return; // ja aberta ou abrindo
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
&& checkSelfPermission(Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED) return;
try {
camMan = (CameraManager) getSystemService(CAMERA_SERVICE);
if (camMan == null) return;
String id = acharFrontal();
if (id == null) {
Log.i(TAG_MOV, "sem camera frontal neste aparelho");
querMovimento = false;
return;
}
if (fioCam == null) { // a thread do detector, so uma vez
fioCam = new HandlerThread("cam");
fioCam.start();
manipCam = new Handler(fioCam.getLooper());
}
abrindoCamara = true;
camMan.openCamera(id, retornoCam, manipCam);
Log.i(TAG_MOV, "abrindo a camera frontal " + id);
} catch (Exception e) {
Log.w(TAG_MOV, "falha ao abrir a camera: " + e);
pararMovimento();
}
}
/** Fecha a camera e a thread do detector. */
private void pararMovimento() {
querMovimento = false;
abrindoCamara = false;
liberarCaptura();
if (fioCam != null) { fioCam.quitSafely(); fioCam = null; }
manipCam = null;
camMan = null;
}
/** Fecha sessao, camera e leitor (sem mexer na thread do detector). */
private void liberarCaptura() {
if (sessao != null) { try { sessao.close(); } catch (Exception e) { /* ja fechou */ } sessao = null; }
if (camara != null) { try { camara.close(); } catch (Exception e) { /* ja fechou */ } camara = null; }
if (leitor != null) { leitor.close(); leitor = null; }
lumaAnt = null;
lumaAtual = null;
quadrosMov = 0;
}
private String acharFrontal() throws CameraAccessException {
for (String id : camMan.getCameraIdList()) {
Integer face = camMan.getCameraCharacteristics(id).get(CameraCharacteristics.LENS_FACING);
int desejada = remoto.config().optString("cameraLado", "user").equals("environment")
? CameraCharacteristics.LENS_FACING_BACK : CameraCharacteristics.LENS_FACING_FRONT;
if (face != null && face == desejada) return id;
}
return null;
}
private final CameraDevice.StateCallback retornoCam = new CameraDevice.StateCallback() {
@Override public void onOpened(CameraDevice d) {
abrindoCamara = false;
camara = d;
abrirSessao(d);
}
@Override public void onDisconnected(CameraDevice d) {
Log.i(TAG_MOV, "camera desconectada");
abrindoCamara = false;
try { d.close(); } catch (Exception e) { /* ja fechou */ }
liberarCaptura();
}
@Override public void onError(CameraDevice d, int e) {
Log.w(TAG_MOV, "erro na camera: " + e);
abrindoCamara = false;
try { d.close(); } catch (Exception e2) { /* ja fechou */ }
liberarCaptura();
}
};
/** Escolhe um ImageReader pequeno (YUV) e comeca a receber quadros. */
private void abrirSessao(CameraDevice dev) {
if (!querMovimento) { dev.close(); camara = null; return; }
try {
StreamConfigurationMap mapa = camMan.getCameraCharacteristics(dev.getId())
.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
Size tam = menorTamanho(mapa);
Integer sensor = camMan.getCameraCharacteristics(dev.getId()).get(CameraCharacteristics.SENSOR_ORIENTATION);
int display = getWindowManager().getDefaultDisplay().getRotation() * 90;
rotacaoPreview = (360 - ((sensor == null ? 0 : sensor) + display) % 360) % 360;
if (tam == null) { Log.w(TAG_MOV, "sem tamanho YUV disponivel"); return; }
leitor = ImageReader.newInstance(tam.getWidth(), tam.getHeight(),
android.graphics.ImageFormat.YUV_420_888, 2);
lumaAnt = null;
lumaAtual = null;
quadrosMov = 0;
leitor.setOnImageAvailableListener(ouvinteQuadros, manipCam);
dev.createCaptureSession(Arrays.asList(leitor.getSurface()), retornoSessao, manipCam);
Log.i(TAG_MOV, "detector " + tam.getWidth() + "x" + tam.getHeight());
} catch (Exception e) {
Log.w(TAG_MOV, "falha ao montar a sessao: " + e);
}
}
/** O menor formato YUV util (o detector nao precisa de resolucao). */
private static Size menorTamanho(StreamConfigurationMap mapa) {
if (mapa == null) return null;
Size[] todos = mapa.getOutputSizes(android.graphics.ImageFormat.YUV_420_888);
if (todos == null || todos.length == 0) return null;
Size melhor = null;
for (Size s : todos) {
if (s.getWidth() < 640 || s.getHeight() < 360) continue;
if (melhor == null || (long) s.getWidth() * s.getHeight()
< (long) melhor.getWidth() * melhor.getHeight()) melhor = s;
}
if (melhor == null) { // nenhuma acima de 160: pega a menor
for (Size s : todos)
if (melhor == null || (long) s.getWidth() * s.getHeight()
< (long) melhor.getWidth() * melhor.getHeight()) melhor = s;
}
return melhor;
}
private final CameraCaptureSession.StateCallback retornoSessao =
new CameraCaptureSession.StateCallback() {
@Override public void onConfigured(CameraCaptureSession s) {
if (!querMovimento || camara == null) return;
try {
sessao = s;
CaptureRequest.Builder pedido = camara.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
pedido.addTarget(leitor.getSurface());
pedido.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON);
s.setRepeatingRequest(pedido.build(), null, manipCam);
Log.i(TAG_MOV, "detector ligado");
} catch (Exception e) {
Log.w(TAG_MOV, "falha no pedido de captura: " + e);
}
}
@Override public void onConfigureFailed(CameraCaptureSession s) {
Log.w(TAG_MOV, "nao consegui configurar a captura");
}
};
private final ImageReader.OnImageAvailableListener ouvinteQuadros =
new ImageReader.OnImageAvailableListener() {
@Override public void onImageAvailable(ImageReader r) {
Image img;
try { img = r.acquireLatestImage(); } catch (Exception e) { return; }
if (img == null) return;
try {
if (movimentoLigado) analisar(img);
remoto.quadro(img, rotacaoPreview);
} catch (Exception e) { /* quadro ruim: segue */ }
finally { img.close(); }
}
};
/** Compara a luminancia do quadro com a do anterior: umidade de movimento e centro. */
private void analisar(Image img) {
int w = img.getWidth(), h = img.getHeight();
if (w <= 0 || h <= 0) return;
Image.Plane plano = img.getPlanes()[0];
ByteBuffer y = plano.getBuffer();
int linha = plano.getRowStride();
int passoPx = plano.getPixelStride();
int passo = 1;
while (passo < 16 && (w / passo) * (h / passo) > AMOSTRAS_MOV) passo++;
int colunas = (w + passo - 1) / passo;
int linhas = (h + passo - 1) / passo;
int total = colunas * linhas;
if (lumaAnt == null || lumaAnt.length != total || lumaAtual == null
|| lumaAtual.length != total) {
lumaAnt = new int[total];
lumaAtual = new int[total];
return; // primeiro quadro: so guarda
}
int n = 0, mexidos = 0;
long sx = 0, sy = 0, somaLuz = 0;
for (int yy = 0; yy < h; yy += passo) {
int base = yy * linha;
for (int xx = 0; xx < w; xx += passo) {
int l = y.get(base + xx * passoPx) & 0xFF;
somaLuz += l;
if (Math.abs(l - lumaAnt[n]) > LIMIAR_MOV) { mexidos++; sx += xx; sy += yy; }
lumaAtual[n] = l;
n++;
}
}
int[] troca = lumaAnt; lumaAnt = lumaAtual; lumaAtual = troca;
// aquecimento: a exposicao ainda esta assentando nos primeiros quadros,
// o que daria um pico de "100% de movimento" e um susto falso
quadrosMov++;
if (quadrosMov <= 3) return;
long agora = System.currentTimeMillis();
double vel = Math.min(1.0, mexidos / ((double) total * 0.25));
double cx = mexidos > 0 ? (double) sx / mexidos / w : 0.5;
double cy = mexidos > 0 ? (double) sy / mexidos / h : 0.5;
if (agora >= proximoLogMov) { // uma linha a cada 2 s (logcat)
proximoLogMov = agora + 2000;
Log.i(TAG_MOV, "movimento=" + Math.round(vel * 100) + "% centro=" +
Math.round(cx * 100) + "," + Math.round(cy * 100) + "% brilho=" +
Math.round(somaLuz / (double) total));
}
if (agora - ultimoEnvioMov < INTERVALO_MOV) return;
ultimoEnvioMov = agora;
enviarMovimento(vel, cx, cy);
}
private void enviarMovimento(final double vel, final double cx, final double cy) {
if (!cameraPermitida() || !emPrimeiroPlano) return;
if (vel >= remoto.movlim()) ultimoUsoTela = System.currentTimeMillis();
final String js = "window.eilik&&window.eilik.movimento(" +
fmt(vel) + "," + fmt(cx) + "," + fmt(cy) + ")";
runOnUiThread(new Runnable() {
@Override public void run() {
try { web.evaluateJavascript(js, null); } catch (Exception e) { /* pagina nao pronta */ }
}
});
}
/** Numero com ponto decimal (a pagina espera 0.42, nao 0,42). */
private static String fmt(double v) {
return String.format(java.util.Locale.US, "%.3f", v);
}
private String aparelhoJson = "null";
private long aparelhoEm = 0;
/** IP, modelo, Android, versão do app e sinal do Wi-Fi; recalculado a cada 10 s. */
private String infoAparelho() {
long agora = System.currentTimeMillis();
if (agora - aparelhoEm < 10000) return aparelhoJson;
aparelhoEm = agora;
try {
JSONObject a = new JSONObject();
a.put("ip", ipLocal());
a.put("modelo", (Build.MANUFACTURER + " " + Build.MODEL).trim());
a.put("android", Build.VERSION.RELEASE);
try { a.put("app", getPackageManager().getPackageInfo(getPackageName(), 0).versionName); }
catch (Exception e) { a.put("app", JSONObject.NULL); }
try {
android.net.wifi.WifiManager wifi = (android.net.wifi.WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE);
android.net.wifi.WifiInfo info = wifi == null ? null : wifi.getConnectionInfo();
// Sem localização o Android 10+ esconde o ID da rede, mas o RSSI continua valendo.
if (info != null && info.getRssi() > -127 && info.getRssi() < 0) {
a.put("wifi", android.net.wifi.WifiManager.calculateSignalLevel(info.getRssi(), 5));
a.put("rssi", info.getRssi());
}
} catch (Exception e) { /* sem permissão ou sem Wi-Fi */ }
aparelhoJson = a.toString();
} catch (Exception e) {
aparelhoJson = "null";
}
return aparelhoJson;
}
/** Primeiro IPv4 privado da rede (Wi-Fi), sem precisar de permissão extra. */
private static String ipLocal() {
try {
for (java.net.NetworkInterface n : java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) {
if (!n.isUp() || n.isLoopback()) continue;
for (java.net.InetAddress e : java.util.Collections.list(n.getInetAddresses())) {
if (e instanceof java.net.Inet4Address && e.isSiteLocalAddress()) return e.getHostAddress();
}
}
} catch (Exception e) { /* sem rede */ }
return null;
}
private void atualizarBrilho() {
if (naCamera) return;
web.evaluateJavascript("window.carinhaAparelho=" + infoAparelho(), null);
Intent bateria = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
if (bateria != null) {
int level = bateria.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
int scale = bateria.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
int status = bateria.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
if (level >= 0 && scale > 0) {
int percent = Math.round(level * 100f / scale);
boolean charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL;
web.evaluateJavascript("window.carinhaBateria={nivel:" + percent + ",carregando:" + charging + "}", null);
}
}
web.evaluateJavascript("window.eilik?({dormindo:window.eilik.face.dormindo,comando:window.carinhaUltimoComando||0,ocupado:(Date.now()-(window.carinhaUltimoComando||0)<3000)&&!!(window.eilik.face.kf||window.eilik.face.fila.length)}):null",
new ValueCallback<String>() {
@Override public void onReceiveValue(String valor) {
if (!emPrimeiroPlano) return;
try {
JSONObject estado = new JSONObject(valor);
dormindo = estado.optBoolean("dormindo");
atualizarSensores();
ultimoUsoTela = Math.max(ultimoUsoTela, estado.optLong("comando", 0));
float nivel = BrilhoRepouso.nivel(remoto.economia(), remoto.brilho(), remoto.brilhoRepouso(),
System.currentTimeMillis() - ultimoUsoTela, remoto.repousoTela(),
estado.optBoolean("ocupado"), estado.optBoolean("dormindo"));
WindowManager.LayoutParams attr = getWindow().getAttributes();
if (Math.abs(attr.screenBrightness - nivel) > 0.005f) {
attr.screenBrightness = nivel;
getWindow().setAttributes(attr);
Log.i("CarinhaPC", "brilho=" + nivel);
}
web.evaluateJavascript("window.carinhaBrilho=" + fmt(nivel), null);
} catch (Exception e) { /* rosto ainda carregando */ }
}
});
}
private final class PonteRTC {
@JavascriptInterface public void rastrear(final String json) {
if (!emPrimeiroPlano || !cameraPermitida() || !remoto.config().optBoolean("seguirRastreamento", true)) return;
try {
final JSONObject alvo = "null".equals(json) ? null : new JSONObject(json);
if (alvo != null) {
double x = alvo.getDouble("x"), y = alvo.getDouble("y");
if (Double.isNaN(x) || Double.isNaN(y) || x<0 || x>1 || y<0 || y>1) return;
String tipo=alvo.optString("tipo");
if (!("mao".equals(tipo) && remoto.config().optBoolean("rastrearMaos", true)) &&
!("rosto".equals(tipo) && remoto.config().optBoolean("rastrearRosto", true))) return;
if (alvo.optBoolean("atividade")) ultimoUsoTela=System.currentTimeMillis();
}
runOnUiThread(new Runnable() {
@Override public void run() {
if (emPrimeiroPlano && cameraPermitida()) web.evaluateJavascript("window.eilik&&window.eilik.rastrear("+
(alvo==null?"null":alvo.toString())+")",null);
}
});
} catch(Exception e) { Log.w("CarinhaRTC","Alvo invalido"); }
}
@JavascriptInterface public void enviar(String json) { remoto.sinalRTC(json); }
@JavascriptInterface public void movimento(double vel, double cx, double cy) {
if (emPrimeiroPlano) enviarMovimento(vel, cx, cy);
}
@JavascriptInterface public void estado(final String json) {
try {
final String seguro = new JSONObject(json).toString();
runOnUiThread(new Runnable() {
@Override public void run() { if (emPrimeiroPlano) web.evaluateJavascript("window.carinhaVideo=" + seguro, null); }
});
} catch (Exception e) { Log.w("CarinhaRTC", "estado invalido"); }
}
}
}