Sem servidor o app não finge mais que está tudo normal: - o app guarda a última aparência e os ajustes vindos da placa (ponte CarinhaApp); a cópia do APK abre com eles; - a carinha dorme e não acorda com nada; no canto direito aparece a placa ✕ celular animada com "sem conexão com a placa"; - o app tenta a placa a cada 10 s e volta sozinho; se a placa cai com a página aberta, em 15 s ela dorme e mostra o aviso; - endereço padrão passa a ser a Orange Pi (192.168.1.35:8100). Também no app (0.9.0): - modo quiosque como dono do aparelho (Quiosque/Admin, menu "Modo quiosque"); a Samsung recusa com bootloader desbloqueado, vale no LineageOS; - carinha como tela inicial (HOME); - android/enxugar-s10.ps1 desativa 57 apps sem uso no S10 (-Reativar desfaz). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1266 lines
59 KiB
Java
1266 lines
59 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.35:8100"; // a Orange Pi
|
|
private static final long ESPERA_SERVIDOR = 10000; // ms entre tentativas sem servidor
|
|
/** O que a pagina pode guardar no app (sobrevive sem servidor; a copia do APK le). */
|
|
private static final java.util.Set<String> LEMBRAVEIS =
|
|
new java.util.HashSet<String>(Arrays.asList("aparencia", "config"));
|
|
|
|
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();
|
|
if (remoto.fotoPendente() > 0 && rtcAtivo)
|
|
rtcWeb.evaluateJavascript("window.rtcFoto&&window.rtcFoto()", null);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
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.addJavascriptInterface(new PonteApp(), "CarinhaApp");
|
|
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",
|
|
"Modo quiosque"};
|
|
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 if (i == 6) pedirToken();
|
|
else menuQuiosque();
|
|
}
|
|
})
|
|
.show();
|
|
}
|
|
|
|
/** Manutenção: soltar a tela para usar o Android, ou deixar de ser dono do aparelho. */
|
|
private void menuQuiosque() {
|
|
if (!Quiosque.dono(this)) {
|
|
new AlertDialog.Builder(this)
|
|
.setTitle("Modo quiosque desligado")
|
|
.setMessage("Para ligar, pelo PC:\nadb shell dpm set-device-owner br.carinha.rosto/.Admin")
|
|
.setPositiveButton("OK", null)
|
|
.show();
|
|
return;
|
|
}
|
|
final boolean pausado = Quiosque.pausado();
|
|
new AlertDialog.Builder(this)
|
|
.setTitle(pausado ? "Quiosque pausado" : "Quiosque ligado")
|
|
.setItems(new String[]{
|
|
pausado ? "Travar de novo" : "Pausar até reiniciar (abre o Android)",
|
|
"Desligar o modo quiosque de vez"}, new DialogInterface.OnClickListener() {
|
|
@Override
|
|
public void onClick(DialogInterface d, int i) {
|
|
if (i == 0) {
|
|
if (pausado) { Quiosque.retomar(MainActivity.this); aviso("quiosque ligado"); }
|
|
else { Quiosque.pausar(MainActivity.this); aviso("quiosque pausado: Home e barra liberados até reiniciar"); }
|
|
} else confirmarRemoverQuiosque();
|
|
}
|
|
})
|
|
.show();
|
|
}
|
|
|
|
private void confirmarRemoverQuiosque() {
|
|
new AlertDialog.Builder(this)
|
|
.setTitle("Desligar o modo quiosque?")
|
|
.setMessage("A carinha deixa de ser dona do aparelho. Para voltar, será preciso o ADB de novo.")
|
|
.setPositiveButton("Desligar", new DialogInterface.OnClickListener() {
|
|
@Override
|
|
public void onClick(DialogInterface d, int w) {
|
|
Quiosque.remover(MainActivity.this);
|
|
aviso("modo quiosque desligado");
|
|
}
|
|
})
|
|
.setNegativeButton("Cancelar", null)
|
|
.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: a copia do APK mostra a carinha dormindo, com a ultima aparencia
|
|
* guardada, e o desenho "placa x celular". Enquanto isso o app tenta o servidor de novo.
|
|
*/
|
|
private void cairParaLocal(WebView vista) {
|
|
if (jaCaiuParaLocal) return;
|
|
jaCaiuParaLocal = true;
|
|
usandoLocal = true;
|
|
naCamera = false;
|
|
Log.i(TAG, "sem servidor " + servidor + ": rosto local dormindo, tentando de novo");
|
|
vista.loadUrl("file:///android_asset/carinha.html");
|
|
procurarServidor();
|
|
}
|
|
|
|
/** Sem servidor: a cada 10 s pergunta se ele voltou; voltou, abre o rosto dele de novo. */
|
|
private void procurarServidor() {
|
|
web.removeCallbacks(tentarServidor);
|
|
web.postDelayed(tentarServidor, ESPERA_SERVIDOR);
|
|
}
|
|
|
|
private final Runnable tentarServidor = new Runnable() {
|
|
@Override public void run() {
|
|
if (!usandoLocal) return;
|
|
if (!emPrimeiroPlano) { procurarServidor(); return; }
|
|
final String alvo = servidor;
|
|
new Thread(new Runnable() {
|
|
@Override public void run() {
|
|
final boolean voltou = servidorResponde(alvo);
|
|
runOnUiThread(new Runnable() {
|
|
@Override public void run() {
|
|
if (!usandoLocal || !alvo.equals(servidor)) return;
|
|
if (!voltou) { procurarServidor(); return; }
|
|
Log.i(TAG, "servidor voltou: " + servidor);
|
|
usandoLocal = false;
|
|
jaCaiuParaLocal = false;
|
|
abrirRosto();
|
|
}
|
|
});
|
|
}
|
|
}, "procura-servidor").start();
|
|
}
|
|
};
|
|
|
|
/** Qualquer resposta HTTP (até 401 de token) quer dizer que o servidor está no ar. */
|
|
private static boolean servidorResponde(String endereco) {
|
|
java.net.HttpURLConnection c = null;
|
|
try {
|
|
c = (java.net.HttpURLConnection) new java.net.URL(endereco + "/config").openConnection();
|
|
c.setConnectTimeout(2500);
|
|
c.setReadTimeout(2500);
|
|
c.setUseCaches(false);
|
|
return c.getResponseCode() > 0;
|
|
} catch (Exception e) {
|
|
return false;
|
|
} finally {
|
|
if (c != null) c.disconnect();
|
|
}
|
|
}
|
|
|
|
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();
|
|
Quiosque.aplicar(this);
|
|
// voltou do segundo plano: atualiza o rosto — mas sem expulsar quem
|
|
// estava vendo a pagina da camera
|
|
if (jaRecarregou && !naCamera) {
|
|
abrirRosto();
|
|
}
|
|
jaRecarregou = true;
|
|
if (usandoLocal) procurarServidor();
|
|
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),apagada:!!window.carinhaTelaApagada}):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"));
|
|
if (estado.optBoolean("apagada")) nivel = 0f; // tela preta + brilho mínimo
|
|
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 */ }
|
|
}
|
|
});
|
|
}
|
|
|
|
/** window.CarinhaApp: guarda a ultima aparencia/ajustes vindos do servidor para a copia do APK. */
|
|
private final class PonteApp {
|
|
@JavascriptInterface public void guardar(String chave, String json) {
|
|
if (!LEMBRAVEIS.contains(chave) || json == null || json.length() > 50000) return;
|
|
try { new JSONObject(json); } catch (Exception e) { return; }
|
|
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString("ultimo_" + chave, json).apply();
|
|
}
|
|
@JavascriptInterface public String lembrado(String chave) {
|
|
if (!LEMBRAVEIS.contains(chave)) return null;
|
|
return getSharedPreferences(PREFS, MODE_PRIVATE).getString("ultimo_" + chave, null);
|
|
}
|
|
@JavascriptInterface public String servidor() { return servidor; }
|
|
}
|
|
|
|
private final class PonteRTC {
|
|
/** Foto do vídeo ao vivo (data:image/jpeg;base64,...) para o histórico. */
|
|
@JavascriptInterface public void foto(final String dataUrl) {
|
|
try {
|
|
int virgula = dataUrl == null ? -1 : dataUrl.indexOf(',');
|
|
if (virgula < 0 || dataUrl.length() > 4_000_000) return;
|
|
remoto.fotoRTC(android.util.Base64.decode(dataUrl.substring(virgula + 1), android.util.Base64.DEFAULT));
|
|
} catch (Exception e) { Log.w("CarinhaPC", "foto do video falhou: " + e); }
|
|
}
|
|
@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"); }
|
|
}
|
|
}
|
|
}
|