feat(celular): sem a placa a carinha dorme e mostra placa ✕ celular

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>
This commit is contained in:
julio
2026-10-08 03:17:28 -03:00
co-authored by Claude Opus 5.5
parent f8d2ebf705
commit 43770c6d67
8 changed files with 620 additions and 29 deletions
+19 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="br.carinha.rosto"
android:versionCode="11"
android:versionName="0.8.3">
android:versionCode="12"
android:versionName="0.9.0">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
@@ -36,6 +36,23 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- no modo quiosque a carinha é a tela inicial do aparelho -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- dono do aparelho (modo quiosque): adb shell dpm set-device-owner br.carinha.rosto/.Admin -->
<receiver
android:name=".Admin"
android:exported="true"
android:permission="android.permission.BIND_DEVICE_ADMIN">
<meta-data android:name="android.app.device_admin" android:resource="@xml/admin" />
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
</intent-filter>
</receiver>
</application>
</manifest>
+156 -11
View File
@@ -20,6 +20,52 @@
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
/* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o
desenho fica em pé no canto direito; em pé, o rosto sobe e o desenho vai embaixo. */
#semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh);
z-index:5;pointer-events:none;text-align:center;opacity:.82;font-family:system-ui,sans-serif;
animation:semServidorEntra 1.2s ease-out}
#semServidor svg{display:block;margin:0 auto;overflow:visible;filter:drop-shadow(0 0 3px currentColor)}
#semServidor svg.horizontal{display:none}
#semServidor svg.vertical{height:58vh;width:auto;max-width:100%}
#semServidor svg.vertical .olhinhos{animation-name:semServidorOlharCima}
@media (orientation:portrait){
body.semServidor #tela,body.semServidor #rostoMotor{transform:translateY(-20%) scale(.8)}
#semServidor{right:auto;top:auto;left:50%;bottom:3.5vh;transform:translateX(-50%);width:min(80vw,400px)}
#semServidor svg.horizontal{display:block;width:100%;height:auto}
#semServidor svg.vertical{display:none}
}
#semServidor .titulo{text-wrap:balance;font-weight:600;font-size:clamp(12px,3.2vh,20px);margin-top:.4em;letter-spacing:.02em}
#semServidor .sub{font-size:clamp(10px,2.4vh,15px);opacity:.6;margin-top:.25em;font-variant-numeric:tabular-nums}
#semServidor .placa rect,#semServidor .celular>rect{fill:none;stroke:currentColor;stroke-width:3}
#semServidor .chip{fill:currentColor;fill-opacity:.25}
#semServidor .pino{fill:currentColor;opacity:.7}
#semServidor .led{fill:#ff5a5a;animation:semServidorLed 1.6s steps(1) infinite}
#semServidor .cabo{stroke:currentColor;stroke-width:3;stroke-dasharray:5 6;opacity:.5;fill:none}
#semServidor .arco{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;opacity:0;
animation:semServidorArco 2.4s ease-out infinite}
#semServidor .a2{animation-delay:.3s}
#semServidor .a3{animation-delay:.6s}
#semServidor .xis path{stroke:#ff5a5a;stroke-width:5;stroke-linecap:round;fill:none}
#semServidor .xis{transform-box:fill-box;transform-origin:center;animation:semServidorXis 2.4s ease-in-out infinite}
#semServidor .olhinhos{fill:currentColor;transform-box:fill-box;transform-origin:center;
animation:semServidorOlhar 5s ease-in-out infinite}
#semServidor .boquinha{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round}
#semServidor .celular{transform-box:fill-box;transform-origin:50% 100%;animation:semServidorProcura 4.8s ease-in-out infinite}
#semServidor.cutucado .celular{animation:semServidorChacoalha .5s ease-in-out}
@keyframes semServidorEntra{from{opacity:0}to{opacity:.82}}
/* em pé no canto direito: o celularzinho olha para cima, para a placa */
@keyframes semServidorOlharCima{0%,30%{transform:translateY(-3px) scaleY(1)}34%{transform:translateY(-3px) scaleY(.1)}38%,60%{transform:translateY(-3px) scaleY(1)}
70%,90%{transform:translateY(1px) scaleY(1)}100%{transform:translateY(-3px)}}
@keyframes semServidorArco{0%{opacity:0}25%{opacity:.9}70%,100%{opacity:0}}
@keyframes semServidorXis{0%,55%,100%{transform:scale(1);opacity:.75}65%{transform:scale(1.25);opacity:1}80%{transform:scale(1);opacity:.85}}
@keyframes semServidorLed{0%{opacity:1}50%{opacity:.15}}
/* o celularzinho olha para a placa, olha de volta e pisca */
@keyframes semServidorOlhar{0%,30%{transform:translateX(-3px) scaleY(1)}34%{transform:translateX(-3px) scaleY(.1)}38%,60%{transform:translateX(-3px) scaleY(1)}
70%,90%{transform:translateX(1px) scaleY(1)}100%{transform:translateX(-3px)}}
@keyframes semServidorProcura{0%,100%{transform:rotate(0)}20%{transform:rotate(-5deg)}40%{transform:rotate(0)}}
@keyframes semServidorChacoalha{0%,100%{transform:rotate(0)}25%{transform:rotate(-9deg)}50%{transform:rotate(7deg)}75%{transform:rotate(-4deg)}}
@media(prefers-reduced-motion:reduce){#semServidor *,#semServidor{animation:none!important}#semServidor .arco{opacity:.6}}
</style>
<body>
<svg id="tela" viewBox="0 0 480 320" preserveAspectRatio="xMidYMid meet">
@@ -106,7 +152,7 @@ function cuidarDaTela() {
const agora = performance.now();
if (!r.dormindo) SONO.desde = 0;
else if (!SONO.desde) SONO.desde = agora;
const apagar = SONO.apagar && r.dormindo && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
const apagar = SONO.apagar && r.dormindo && !window.carinhaSemServidor && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
if (apagar !== SONO.apagada) {
SONO.apagada = apagar;
window.carinhaTelaApagada = apagar;
@@ -725,6 +771,11 @@ function trata(ordem) {
r.dormindo = true;
atividade(null);
} else if (ordem === "acordar") {
if (window.carinhaSemServidor) { // sem a placa continua dormindo; o aviso chacoalha
const aviso = document.getElementById("semServidor");
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
return;
}
r.dormindo = false; animar("surpreso");
} else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
@@ -777,27 +828,44 @@ async function buscarOrdens() {
try {
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
if (!resp.ok) return;
conexaoOk();
const lista = await resp.json();
for (const nome of lista) trata(nome);
} catch (e) { /* sem servidor (file://) — ignora */ }
}
setInterval(buscarOrdens, 500);
// O app (window.CarinhaApp) guarda a ultima aparencia e os ajustes vindos do servidor:
// a copia do APK (file://) nao enxerga o armazenamento da pagina do servidor.
const LOCAL = location.protocol === "file:";
function lembrar(chave, valor) {
try { if (window.CarinhaApp && !LOCAL) window.CarinhaApp.guardar(chave, JSON.stringify(valor)); } catch (e) {}
}
function lembrado(chave) {
try { const s = window.CarinhaApp && window.CarinhaApp.lembrado(chave); return s ? JSON.parse(s) : null; } catch (e) { return null; }
}
// A aparencia guardada no servidor (rosto + cor) e aplicada ao carregar: o
// painel de controle personaliza e a carinha ja abre do jeito escolhido. Sem
// servidor (file://) isso falha calado e vale o padrao.
// servidor vale a ultima que o app guardou (ou o padrao).
async function aplicarAparencia() {
try {
const resp = await fetch("config", { cache: "no-store" });
if (!resp.ok) return;
const c = await resp.json();
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
} catch (e) { /* sem servidor — segue com o padrao */ }
let c = null;
if (!LOCAL) {
try {
const resp = await fetch("config", { cache: "no-store" });
if (resp.ok) c = await resp.json();
} catch (e) { /* sem servidor */ }
}
if (!c) c = lembrado("aparencia");
if (!c) return;
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
}
setTimeout(aplicarAparencia, 200);
// O painel troca rosto/cor por ordens: guarda a aparencia atual de tempos em tempos.
setInterval(() => { if (!window.carinhaSemServidor) lembrar("aparencia", { rosto: ROSTO_NOME, formato: FORMATO, cor: CIANO.slice() }); }, 15000);
// ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
// do servidor (SSE) por postMessage.
const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
@@ -1238,6 +1306,7 @@ function configurarApp(c) {
MOV.cooldown = c.movcooldown; MOV.olharMax = c.movolhar;
if (!c.reagir) { MOV.olharX = MOV.olharY = 0; MOV.ultimoMov = 0; }
configRecebida = c;
lembrar('config', c);
if(!c.seguirRastreamento || !c.sensorCamera || !c.reagir) TRACK.alvo=null;
}
let sincronizando = false;
@@ -1363,6 +1432,7 @@ function laco(t) {
if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null);
document.body.classList.toggle("atividade", !!ATIV && !ATIV.saindo);
if (window.carinhaSemServidor && !r.dormindo) { r.fila = []; r.kf = null; r.dormindo = true; } // sem a placa: só dorme
const ocupado = congelado ? true : passo(dt);
if (!ocupado) {
descanso += dt;
@@ -1434,6 +1504,81 @@ setTimeout(relatar, 1500);
setInterval(poll,1000);poll();
})();
/* ---------------------------------------------------------------- sem servidor
Sem a placa (copia do APK, ou o servidor caiu com a pagina aberta) a carinha dorme com a
ultima aparencia e ajustes guardados e mostra placa ✕ celular. Volta sozinha quando a placa
responde (o app recarrega a pagina do servidor; aberta pelo servidor, o SSE reconecta). */
let ultimoOk = performance.now();
const TOLERANCIA_SEM_SERVIDOR = 15000; // ms sem resposta antes de dormir
function conexaoOk() { ultimoOk = performance.now(); }
function mostrarSemServidor(ligar) {
let el = document.getElementById("semServidor");
document.body.classList.toggle("semServidor", ligar);
if (!ligar) { if (el) el.hidden = true; return; }
if (!el) {
el = document.createElement("div");
el.id = "semServidor";
// placa e celular pelo canto de cima à esquerda; o desenho vertical vai no canto direito com o celular deitado
const placa = (px, py) =>
'<g class="placa">' +
'<rect x="' + px + '" y="' + py + '" width="84" height="56" rx="6"/>' +
'<rect class="chip" x="' + (px + 29) + '" y="' + (py + 15) + '" width="26" height="26" rx="3"/>' +
[0,1,2,3,4,5,6,7].map((i) => '<circle class="pino" cx="' + (px + 11 + i * 9) + '" cy="' + (py + 7) + '" r="2"/>').join("") +
'<circle class="pino" cx="' + (px + 7) + '" cy="' + (py + 50) + '" r="2.4"/><circle class="pino" cx="' + (px + 77) + '" cy="' + (py + 50) + '" r="2.4"/>' +
'<circle class="led" cx="' + (px + 72) + '" cy="' + (py + 22) + '" r="2.6"/>' +
'</g>';
const celular = (cx, cy) =>
'<g class="celular">' +
'<rect x="' + cx + '" y="' + cy + '" width="40" height="76" rx="8"/>' +
'<g class="olhinhos"><ellipse cx="' + (cx + 13) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/><ellipse cx="' + (cx + 27) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/></g>' +
'<path class="boquinha" d="M' + (cx + 14) + ' ' + (cy + 48) + ' Q' + (cx + 20) + ' ' + (cy + 44) + ' ' + (cx + 26) + ' ' + (cy + 48) + '"/>' +
'</g>';
const xis = (x, y) => '<g class="xis"><path d="M' + (x - 10) + ' ' + (y - 10) + ' L' + (x + 10) + ' ' + (y + 10) + ' M' + (x + 10) + ' ' + (y - 10) + ' L' + (x - 10) + ' ' + (y + 10) + '"/></g>';
el.innerHTML =
'<svg class="horizontal" viewBox="0 0 300 104" aria-hidden="true">' + placa(18, 30) +
'<path class="cabo" d="M104 58 H146"/>' +
'<g class="arcos">' +
'<path class="arco a1" d="M212 50 A10 10 0 0 0 212 66"/>' +
'<path class="arco a2" d="M203 44 A18 18 0 0 0 203 72"/>' +
'<path class="arco a3" d="M194 38 A26 26 0 0 0 194 78"/>' +
'</g>' + xis(160, 58) + celular(220, 18) +
'</svg>' +
'<svg class="vertical" viewBox="0 0 120 262" aria-hidden="true">' + placa(18, 8) +
'<path class="cabo" d="M60 66 V108"/>' +
'<g class="arcos">' +
'<path class="arco a1" d="M52 172 A10 10 0 0 1 68 172"/>' +
'<path class="arco a2" d="M46 163 A18 18 0 0 1 74 163"/>' +
'<path class="arco a3" d="M40 154 A26 26 0 0 1 80 154"/>' +
'</g>' + xis(60, 122) + celular(40, 182) +
'</svg>' +
'<div class="titulo">sem conexão com a placa</div>' +
'<div class="sub"></div>';
document.body.appendChild(el);
}
let alvo = "";
try { alvo = (window.CarinhaApp && window.CarinhaApp.servidor()) || (LOCAL ? "" : location.host); } catch (e) {}
el.querySelector(".sub").textContent = "tentando de novo" + (alvo ? " · " + String(alvo).replace(/^https?:\/\//, "") : "") + "…";
el.style.color = "rgb(" + CIANO.map(Math.round).join(",") + ")";
el.hidden = false;
}
function checarServidor() {
if (MODO_PAINEL || par.get("espelho") === "1") return;
if (window.carinhaSSE) conexaoOk();
const fora = LOCAL || performance.now() - ultimoOk > TOLERANCIA_SEM_SERVIDOR;
if (window.carinhaSemServidor) mostrarSemServidor(true); // acompanha a cor guardada
if (fora === !!window.carinhaSemServidor) return;
window.carinhaSemServidor = fora;
console.log(fora ? "sem servidor: dormindo" : "servidor de volta");
mostrarSemServidor(fora);
if (fora) { atividade(null); r.fila = []; r.kf = null; r.dormindo = true; }
else trata("acordar");
}
// Copia do APK: os ultimos ajustes guardados (boca, sobrancelhas, sono...) valem também aqui.
setTimeout(() => { if (LOCAL) { const c = lembrado("config"); if (c) configurarApp(c); } checarServidor(); }, 300);
setInterval(checarServidor, 1000);
</script>
<script src="/reacoes-render.js"></script>
<script>
@@ -0,0 +1,10 @@
package br.carinha.rosto;
import android.app.admin.DeviceAdminReceiver;
/**
* Administrador do aparelho. Vira "dono" (device owner) uma vez, pelo ADB:
* adb shell dpm set-device-owner br.carinha.rosto/.Admin
* A partir daí o app trava a tela nele mesmo (ver Quiosque).
*/
public final class Admin extends DeviceAdminReceiver { }
@@ -85,7 +85,11 @@ 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 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
@@ -247,6 +251,7 @@ public class MainActivity extends Activity {
cfg.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
}
web.addJavascriptInterface(new PonteApp(), "CarinhaApp");
web.setWebViewClient(new WebViewClient() {
@Override
public void onReceivedError(WebView vista, WebResourceRequest pedido,
@@ -414,7 +419,8 @@ public class MainActivity extends Activity {
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"};
"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() {
@@ -426,12 +432,55 @@ public class MainActivity extends Activity {
else if (i == 3) pedirServidor();
else if (i == 4) menuSensores();
else if (i == 5) menuVideo();
else pedirToken();
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);
@@ -500,14 +549,63 @@ public class MainActivity extends Activity {
Toast.makeText(this, texto, Toast.LENGTH_LONG).show();
}
/** Servidor fora do ar: mostra a carinha embutida no APK (sem controle remoto). */
/**
* 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;
aviso("sem servidor " + servidor + " — usando o rosto local");
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() {
@@ -742,12 +840,14 @@ public class MainActivity extends Activity {
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
}
@@ -1106,6 +1206,20 @@ public class MainActivity extends Activity {
});
}
/** 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) {
@@ -0,0 +1,118 @@
package br.carinha.rosto;
import android.Manifest;
import android.app.Activity;
import android.app.ActivityManager;
import android.app.admin.DevicePolicyManager;
import android.app.admin.SystemUpdatePolicy;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.BatteryManager;
import android.os.Build;
import android.provider.Settings;
import android.util.Log;
/**
* Modo quiosque: com o app como dono do aparelho, a carinha é a tela inicial,
* abre sozinha ao ligar e ninguém sai dela (sem barra de status, sem tela de bloqueio).
* Sem ser dono, tudo aqui é ignorado e o app funciona como antes.
*/
public final class Quiosque {
private static final String TAG = "CarinhaQuiosque";
/** Pausado pelo menu para manutenção; volta sozinho quando o celular reinicia. */
private static boolean pausado;
private Quiosque() { }
private static DevicePolicyManager dpm(Context c) {
return (DevicePolicyManager) c.getSystemService(Context.DEVICE_POLICY_SERVICE);
}
private static ComponentName admin(Context c) { return new ComponentName(c, Admin.class); }
public static boolean dono(Context c) {
DevicePolicyManager d = dpm(c);
return d != null && d.isDeviceOwnerApp(c.getPackageName());
}
public static boolean pausado() { return pausado; }
/** Chamado no onResume: reaplica as travas (idempotente). */
public static void aplicar(Activity a) {
if (pausado || !dono(a)) return;
DevicePolicyManager d = dpm(a);
ComponentName adm = admin(a);
String pacote = a.getPackageName();
try {
d.setLockTaskPackages(adm, new String[]{pacote});
// a carinha é a tela inicial: botão Home e o fim do boot caem nela
IntentFilter inicio = new IntentFilter(Intent.ACTION_MAIN);
inicio.addCategory(Intent.CATEGORY_HOME);
inicio.addCategory(Intent.CATEGORY_DEFAULT);
d.addPersistentPreferredActivity(adm, inicio, new ComponentName(a, MainActivity.class));
// tela ligada enquanto estiver na tomada, USB ou carregador sem fio
d.setGlobalSetting(adm, Settings.Global.STAY_ON_WHILE_PLUGGED_IN, String.valueOf(
BatteryManager.BATTERY_PLUGGED_AC | BatteryManager.BATTERY_PLUGGED_USB | BatteryManager.BATTERY_PLUGGED_WIRELESS));
if (Build.VERSION.SDK_INT >= 23) {
d.setKeyguardDisabled(adm, true);
d.setStatusBarDisabled(adm, true);
// atualização do sistema só de madrugada (3h–5h), nunca no meio do dia
d.setSystemUpdatePolicy(adm, SystemUpdatePolicy.createWindowedInstallPolicy(180, 300));
// câmera e microfone sem perguntar, inclusive depois de reinstalar o app
for (String p : new String[]{Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO})
d.setPermissionGrantState(adm, pacote, p, DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED);
}
// o botão Ligar continua abrindo o menu de desligar/reiniciar
if (Build.VERSION.SDK_INT >= 28)
d.setLockTaskFeatures(adm, DevicePolicyManager.LOCK_TASK_FEATURE_GLOBAL_ACTIONS);
if (!travado(a)) a.startLockTask();
} catch (Exception e) {
Log.e(TAG, "aplicar: " + e);
}
}
/** Solta a tela até o próximo reinício, para mexer nas configurações do Android. */
public static void pausar(Activity a) {
if (!dono(a)) return;
pausado = true;
DevicePolicyManager d = dpm(a);
ComponentName adm = admin(a);
try {
if (travado(a)) a.stopLockTask();
d.clearPackagePersistentPreferredActivities(adm, a.getPackageName());
if (Build.VERSION.SDK_INT >= 23) {
d.setKeyguardDisabled(adm, false);
d.setStatusBarDisabled(adm, false);
}
} catch (Exception e) {
Log.e(TAG, "pausar: " + e);
}
}
/** Volta a travar sem precisar reiniciar. */
public static void retomar(Activity a) {
pausado = false;
aplicar(a);
}
/** Deixa de ser dono do aparelho de vez (para desinstalar ou trocar de app). */
@SuppressWarnings("deprecation")
public static void remover(Activity a) {
if (!dono(a)) return;
pausar(a);
try {
dpm(a).clearDeviceOwnerApp(a.getPackageName());
} catch (Exception e) {
Log.e(TAG, "remover: " + e);
}
}
private static boolean travado(Activity a) {
ActivityManager am = (ActivityManager) a.getSystemService(Context.ACTIVITY_SERVICE);
if (am == null) return false;
if (Build.VERSION.SDK_INT >= 23) return am.getLockTaskModeState() != ActivityManager.LOCK_TASK_MODE_NONE;
return am.isInLockTaskMode();
}
}
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Como dono do aparelho, o quiosque não precisa de políticas de administrador antigas -->
<device-admin>
<uses-policies />
</device-admin>
+37
View File
@@ -0,0 +1,37 @@
# Desativa no S10 os apps que não servem ao robô (reversível: .\enxugar-s10.ps1 -Reativar)
param([switch]$Reativar)
$adb = 'C:/Users/JullioPC/Documents/Open/orange-pi-zero3/android/tools/android-sdk/platform-tools/adb.exe'
$pacotes = @(
# Bixby
'com.samsung.android.bixby.agent', 'com.samsung.android.bixby.service', 'com.samsung.android.bixby.wakeup',
'com.samsung.systemui.bixby2', 'com.samsung.android.app.settings.bixby', 'com.samsung.android.visionintelligence',
'com.samsung.android.app.spage', 'com.samsung.android.app.routines',
# enfeites da Samsung
'com.samsung.android.app.tips', 'com.samsung.android.aremoji', 'com.samsung.android.ardrawing', 'com.samsung.android.arzone',
'com.samsung.android.livestickers', 'com.samsung.android.stickercenter', 'com.samsung.android.app.camera.sticker.facearavatar.preload',
'com.samsung.android.app.dressroom', 'com.samsung.android.app.ledbackcover', 'com.samsung.android.app.watchmanagerstub',
'com.samsung.android.themestore', 'com.samsung.android.app.aodservice', 'com.samsung.android.game.gamehome',
'com.samsung.android.game.gametools', 'com.samsung.android.app.reminder', 'com.samsung.android.forest',
'com.sec.android.app.vepreload', 'com.samsung.android.mateagent', 'com.samsung.android.beaconmanager',
'com.samsung.android.kidsinstaller',
# contas e loja Samsung
'com.sec.android.app.samsungapps', 'com.sec.android.app.billing', 'com.samsung.android.scloud',
'com.samsung.android.samsungpass', 'com.samsung.android.samsungpassautofill', 'com.samsung.android.spayfw',
'com.samsung.android.smartswitchassistant', 'com.sec.android.easyMover.Agent', 'com.samsung.android.easysetup',
'com.samsung.android.app.sharelive', 'com.sec.android.app.desktoplauncher', 'com.sec.android.desktopmode.uiservice',
'com.samsung.android.mdx', 'com.samsung.android.mdx.kit', 'com.samsung.android.mdx.quickboard',
# apps de uso pessoal
'com.samsung.android.messaging', 'com.samsung.android.calendar', 'com.google.android.gm', 'com.google.android.youtube',
'com.google.android.apps.maps', 'com.google.android.apps.tachyon', 'com.google.android.googlequicksearchbox',
# pré-instalados
'com.facebook.katana', 'com.facebook.services', 'com.facebook.system', 'com.facebook.appmanager',
'com.microsoft.skydrive', 'com.microsoft.appmanager', 'br.com.bemobi.appsclub.vivo'
)
$acao = if ($Reativar) { 'enable' } else { 'disable-user --user 0' }
$ok = 0; $falhas = @()
foreach ($p in $pacotes) {
$r = & $adb -d shell "pm $acao $p" 2>&1 | Out-String
if ($r -match 'new state') { $ok++ } else { $falhas += "$p -> $($r.Trim())" }
}
"$ok de $($pacotes.Count) " + $(if ($Reativar) { 'reativados' } else { 'desativados' })
$falhas
+156 -11
View File
@@ -20,6 +20,52 @@
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
/* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o
desenho fica em pé no canto direito; em pé, o rosto sobe e o desenho vai embaixo. */
#semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh);
z-index:5;pointer-events:none;text-align:center;opacity:.82;font-family:system-ui,sans-serif;
animation:semServidorEntra 1.2s ease-out}
#semServidor svg{display:block;margin:0 auto;overflow:visible;filter:drop-shadow(0 0 3px currentColor)}
#semServidor svg.horizontal{display:none}
#semServidor svg.vertical{height:58vh;width:auto;max-width:100%}
#semServidor svg.vertical .olhinhos{animation-name:semServidorOlharCima}
@media (orientation:portrait){
body.semServidor #tela,body.semServidor #rostoMotor{transform:translateY(-20%) scale(.8)}
#semServidor{right:auto;top:auto;left:50%;bottom:3.5vh;transform:translateX(-50%);width:min(80vw,400px)}
#semServidor svg.horizontal{display:block;width:100%;height:auto}
#semServidor svg.vertical{display:none}
}
#semServidor .titulo{text-wrap:balance;font-weight:600;font-size:clamp(12px,3.2vh,20px);margin-top:.4em;letter-spacing:.02em}
#semServidor .sub{font-size:clamp(10px,2.4vh,15px);opacity:.6;margin-top:.25em;font-variant-numeric:tabular-nums}
#semServidor .placa rect,#semServidor .celular>rect{fill:none;stroke:currentColor;stroke-width:3}
#semServidor .chip{fill:currentColor;fill-opacity:.25}
#semServidor .pino{fill:currentColor;opacity:.7}
#semServidor .led{fill:#ff5a5a;animation:semServidorLed 1.6s steps(1) infinite}
#semServidor .cabo{stroke:currentColor;stroke-width:3;stroke-dasharray:5 6;opacity:.5;fill:none}
#semServidor .arco{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;opacity:0;
animation:semServidorArco 2.4s ease-out infinite}
#semServidor .a2{animation-delay:.3s}
#semServidor .a3{animation-delay:.6s}
#semServidor .xis path{stroke:#ff5a5a;stroke-width:5;stroke-linecap:round;fill:none}
#semServidor .xis{transform-box:fill-box;transform-origin:center;animation:semServidorXis 2.4s ease-in-out infinite}
#semServidor .olhinhos{fill:currentColor;transform-box:fill-box;transform-origin:center;
animation:semServidorOlhar 5s ease-in-out infinite}
#semServidor .boquinha{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round}
#semServidor .celular{transform-box:fill-box;transform-origin:50% 100%;animation:semServidorProcura 4.8s ease-in-out infinite}
#semServidor.cutucado .celular{animation:semServidorChacoalha .5s ease-in-out}
@keyframes semServidorEntra{from{opacity:0}to{opacity:.82}}
/* em pé no canto direito: o celularzinho olha para cima, para a placa */
@keyframes semServidorOlharCima{0%,30%{transform:translateY(-3px) scaleY(1)}34%{transform:translateY(-3px) scaleY(.1)}38%,60%{transform:translateY(-3px) scaleY(1)}
70%,90%{transform:translateY(1px) scaleY(1)}100%{transform:translateY(-3px)}}
@keyframes semServidorArco{0%{opacity:0}25%{opacity:.9}70%,100%{opacity:0}}
@keyframes semServidorXis{0%,55%,100%{transform:scale(1);opacity:.75}65%{transform:scale(1.25);opacity:1}80%{transform:scale(1);opacity:.85}}
@keyframes semServidorLed{0%{opacity:1}50%{opacity:.15}}
/* o celularzinho olha para a placa, olha de volta e pisca */
@keyframes semServidorOlhar{0%,30%{transform:translateX(-3px) scaleY(1)}34%{transform:translateX(-3px) scaleY(.1)}38%,60%{transform:translateX(-3px) scaleY(1)}
70%,90%{transform:translateX(1px) scaleY(1)}100%{transform:translateX(-3px)}}
@keyframes semServidorProcura{0%,100%{transform:rotate(0)}20%{transform:rotate(-5deg)}40%{transform:rotate(0)}}
@keyframes semServidorChacoalha{0%,100%{transform:rotate(0)}25%{transform:rotate(-9deg)}50%{transform:rotate(7deg)}75%{transform:rotate(-4deg)}}
@media(prefers-reduced-motion:reduce){#semServidor *,#semServidor{animation:none!important}#semServidor .arco{opacity:.6}}
</style>
<body>
<svg id="tela" viewBox="0 0 480 320" preserveAspectRatio="xMidYMid meet">
@@ -106,7 +152,7 @@ function cuidarDaTela() {
const agora = performance.now();
if (!r.dormindo) SONO.desde = 0;
else if (!SONO.desde) SONO.desde = agora;
const apagar = SONO.apagar && r.dormindo && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
const apagar = SONO.apagar && r.dormindo && !window.carinhaSemServidor && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
if (apagar !== SONO.apagada) {
SONO.apagada = apagar;
window.carinhaTelaApagada = apagar;
@@ -725,6 +771,11 @@ function trata(ordem) {
r.dormindo = true;
atividade(null);
} else if (ordem === "acordar") {
if (window.carinhaSemServidor) { // sem a placa continua dormindo; o aviso chacoalha
const aviso = document.getElementById("semServidor");
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
return;
}
r.dormindo = false; animar("surpreso");
} else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
@@ -777,27 +828,44 @@ async function buscarOrdens() {
try {
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
if (!resp.ok) return;
conexaoOk();
const lista = await resp.json();
for (const nome of lista) trata(nome);
} catch (e) { /* sem servidor (file://) — ignora */ }
}
setInterval(buscarOrdens, 500);
// O app (window.CarinhaApp) guarda a ultima aparencia e os ajustes vindos do servidor:
// a copia do APK (file://) nao enxerga o armazenamento da pagina do servidor.
const LOCAL = location.protocol === "file:";
function lembrar(chave, valor) {
try { if (window.CarinhaApp && !LOCAL) window.CarinhaApp.guardar(chave, JSON.stringify(valor)); } catch (e) {}
}
function lembrado(chave) {
try { const s = window.CarinhaApp && window.CarinhaApp.lembrado(chave); return s ? JSON.parse(s) : null; } catch (e) { return null; }
}
// A aparencia guardada no servidor (rosto + cor) e aplicada ao carregar: o
// painel de controle personaliza e a carinha ja abre do jeito escolhido. Sem
// servidor (file://) isso falha calado e vale o padrao.
// servidor vale a ultima que o app guardou (ou o padrao).
async function aplicarAparencia() {
try {
const resp = await fetch("config", { cache: "no-store" });
if (!resp.ok) return;
const c = await resp.json();
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
} catch (e) { /* sem servidor — segue com o padrao */ }
let c = null;
if (!LOCAL) {
try {
const resp = await fetch("config", { cache: "no-store" });
if (resp.ok) c = await resp.json();
} catch (e) { /* sem servidor */ }
}
if (!c) c = lembrado("aparencia");
if (!c) return;
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
}
setTimeout(aplicarAparencia, 200);
// O painel troca rosto/cor por ordens: guarda a aparencia atual de tempos em tempos.
setInterval(() => { if (!window.carinhaSemServidor) lembrar("aparencia", { rosto: ROSTO_NOME, formato: FORMATO, cor: CIANO.slice() }); }, 15000);
// ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
// do servidor (SSE) por postMessage.
const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
@@ -1238,6 +1306,7 @@ function configurarApp(c) {
MOV.cooldown = c.movcooldown; MOV.olharMax = c.movolhar;
if (!c.reagir) { MOV.olharX = MOV.olharY = 0; MOV.ultimoMov = 0; }
configRecebida = c;
lembrar('config', c);
if(!c.seguirRastreamento || !c.sensorCamera || !c.reagir) TRACK.alvo=null;
}
let sincronizando = false;
@@ -1363,6 +1432,7 @@ function laco(t) {
if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null);
document.body.classList.toggle("atividade", !!ATIV && !ATIV.saindo);
if (window.carinhaSemServidor && !r.dormindo) { r.fila = []; r.kf = null; r.dormindo = true; } // sem a placa: só dorme
const ocupado = congelado ? true : passo(dt);
if (!ocupado) {
descanso += dt;
@@ -1434,6 +1504,81 @@ setTimeout(relatar, 1500);
setInterval(poll,1000);poll();
})();
/* ---------------------------------------------------------------- sem servidor
Sem a placa (copia do APK, ou o servidor caiu com a pagina aberta) a carinha dorme com a
ultima aparencia e ajustes guardados e mostra placa ✕ celular. Volta sozinha quando a placa
responde (o app recarrega a pagina do servidor; aberta pelo servidor, o SSE reconecta). */
let ultimoOk = performance.now();
const TOLERANCIA_SEM_SERVIDOR = 15000; // ms sem resposta antes de dormir
function conexaoOk() { ultimoOk = performance.now(); }
function mostrarSemServidor(ligar) {
let el = document.getElementById("semServidor");
document.body.classList.toggle("semServidor", ligar);
if (!ligar) { if (el) el.hidden = true; return; }
if (!el) {
el = document.createElement("div");
el.id = "semServidor";
// placa e celular pelo canto de cima à esquerda; o desenho vertical vai no canto direito com o celular deitado
const placa = (px, py) =>
'<g class="placa">' +
'<rect x="' + px + '" y="' + py + '" width="84" height="56" rx="6"/>' +
'<rect class="chip" x="' + (px + 29) + '" y="' + (py + 15) + '" width="26" height="26" rx="3"/>' +
[0,1,2,3,4,5,6,7].map((i) => '<circle class="pino" cx="' + (px + 11 + i * 9) + '" cy="' + (py + 7) + '" r="2"/>').join("") +
'<circle class="pino" cx="' + (px + 7) + '" cy="' + (py + 50) + '" r="2.4"/><circle class="pino" cx="' + (px + 77) + '" cy="' + (py + 50) + '" r="2.4"/>' +
'<circle class="led" cx="' + (px + 72) + '" cy="' + (py + 22) + '" r="2.6"/>' +
'</g>';
const celular = (cx, cy) =>
'<g class="celular">' +
'<rect x="' + cx + '" y="' + cy + '" width="40" height="76" rx="8"/>' +
'<g class="olhinhos"><ellipse cx="' + (cx + 13) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/><ellipse cx="' + (cx + 27) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/></g>' +
'<path class="boquinha" d="M' + (cx + 14) + ' ' + (cy + 48) + ' Q' + (cx + 20) + ' ' + (cy + 44) + ' ' + (cx + 26) + ' ' + (cy + 48) + '"/>' +
'</g>';
const xis = (x, y) => '<g class="xis"><path d="M' + (x - 10) + ' ' + (y - 10) + ' L' + (x + 10) + ' ' + (y + 10) + ' M' + (x + 10) + ' ' + (y - 10) + ' L' + (x - 10) + ' ' + (y + 10) + '"/></g>';
el.innerHTML =
'<svg class="horizontal" viewBox="0 0 300 104" aria-hidden="true">' + placa(18, 30) +
'<path class="cabo" d="M104 58 H146"/>' +
'<g class="arcos">' +
'<path class="arco a1" d="M212 50 A10 10 0 0 0 212 66"/>' +
'<path class="arco a2" d="M203 44 A18 18 0 0 0 203 72"/>' +
'<path class="arco a3" d="M194 38 A26 26 0 0 0 194 78"/>' +
'</g>' + xis(160, 58) + celular(220, 18) +
'</svg>' +
'<svg class="vertical" viewBox="0 0 120 262" aria-hidden="true">' + placa(18, 8) +
'<path class="cabo" d="M60 66 V108"/>' +
'<g class="arcos">' +
'<path class="arco a1" d="M52 172 A10 10 0 0 1 68 172"/>' +
'<path class="arco a2" d="M46 163 A18 18 0 0 1 74 163"/>' +
'<path class="arco a3" d="M40 154 A26 26 0 0 1 80 154"/>' +
'</g>' + xis(60, 122) + celular(40, 182) +
'</svg>' +
'<div class="titulo">sem conexão com a placa</div>' +
'<div class="sub"></div>';
document.body.appendChild(el);
}
let alvo = "";
try { alvo = (window.CarinhaApp && window.CarinhaApp.servidor()) || (LOCAL ? "" : location.host); } catch (e) {}
el.querySelector(".sub").textContent = "tentando de novo" + (alvo ? " · " + String(alvo).replace(/^https?:\/\//, "") : "") + "…";
el.style.color = "rgb(" + CIANO.map(Math.round).join(",") + ")";
el.hidden = false;
}
function checarServidor() {
if (MODO_PAINEL || par.get("espelho") === "1") return;
if (window.carinhaSSE) conexaoOk();
const fora = LOCAL || performance.now() - ultimoOk > TOLERANCIA_SEM_SERVIDOR;
if (window.carinhaSemServidor) mostrarSemServidor(true); // acompanha a cor guardada
if (fora === !!window.carinhaSemServidor) return;
window.carinhaSemServidor = fora;
console.log(fora ? "sem servidor: dormindo" : "servidor de volta");
mostrarSemServidor(fora);
if (fora) { atividade(null); r.fila = []; r.kf = null; r.dormindo = true; }
else trata("acordar");
}
// Copia do APK: os ultimos ajustes guardados (boca, sobrancelhas, sono...) valem também aqui.
setTimeout(() => { if (LOCAL) { const c = lembrado("config"); if (c) configurarApp(c); } checarServidor(); }, 300);
setInterval(checarServidor, 1000);
</script>
<script src="/reacoes-render.js"></script>
<script>