fix(celular): sem anúncio na rede, o app varre a /24 atrás da placa

O roteador/chip Wi-Fi descarta o multicast entre aparelhos sem fio: a placa
não via ninguém e o celular não recebia o _carinha._tcp. Sem anúncio em 6 s,
o app pergunta /app/health a cada endereço da rede (32 em paralelo) e usa
quem responde carinha-pc. Testado no S10: achou .35 em ~17 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-10 03:12:36 -03:00
co-authored by Claude Opus 5.5
parent 91c64e5b18
commit 93f91e491c
+117 -23
View File
@@ -7,55 +7,75 @@ import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* Acha a placa na rede pelo anúncio mDNS "_carinha._tcp" (avahi na Orange Pi, o mesmo do
* carinha.local). Assim, se o IP da placa mudar, o app se reconecta sem ninguém digitar endereço.
* Uma busca por vez; desiste depois de alguns segundos e responde no fio principal.
* Acha a placa na rede quando o endereço salvo não responde, para o app se reconectar sem
* ninguém digitar endereço:
* 1. pelo anúncio mDNS "_carinha._tcp" (avahi na Orange Pi, o mesmo do carinha.local);
* 2. se o anúncio não chegar (alguns roteadores e chips Wi-Fi descartam multicast entre aparelhos
* sem fio), perguntando a cada endereço da rede do celular quem responde /app/health como
* "carinha-pc".
* Uma busca por vez; a resposta chega no fio principal (null = não achou).
*/
final class Descoberta {
interface Resposta { void achou(String endereco); } // null = não achou
interface Resposta { void achou(String endereco); }
private static final String TAG = "CarinhaDescoberta";
private static final String TIPO = "_carinha._tcp.";
private static final long LIMITE = 8000;
private static final long LIMITE_ANUNCIO = 6000;
private static final int PORTA = 8100;
private final NsdManager nsd;
private final Handler principal = new Handler(Looper.getMainLooper());
private NsdManager.DiscoveryListener ouvinte;
private Resposta resposta;
private boolean ocupada;
Descoberta(Context c) {
nsd = (NsdManager) c.getApplicationContext().getSystemService(Context.NSD_SERVICE);
}
boolean procurando() { return ouvinte != null; }
boolean procurando() { return ocupada; }
void procurar(Resposta r) {
if (nsd == null) { r.achou(null); return; }
if (ouvinte != null) return;
if (ocupada) return;
ocupada = true;
resposta = r;
if (nsd == null) { semAnuncio(); return; }
ouvinte = new NsdManager.DiscoveryListener() {
@Override public void onDiscoveryStarted(String tipo) { }
@Override public void onDiscoveryStopped(String tipo) { }
@Override public void onStartDiscoveryFailed(String tipo, int erro) { terminar(null); }
@Override public void onStartDiscoveryFailed(String tipo, int erro) { fimDoAnuncio(null); }
@Override public void onStopDiscoveryFailed(String tipo, int erro) { }
@Override public void onServiceLost(NsdServiceInfo s) { }
@Override public void onServiceFound(NsdServiceInfo s) {
if (!s.getServiceType().startsWith("_carinha._tcp")) return;
resolver(s);
if (s.getServiceType().startsWith("_carinha._tcp")) resolver(s);
}
};
try {
nsd.discoverServices(TIPO, NsdManager.PROTOCOL_DNS_SD, ouvinte);
} catch (Exception e) {
Log.w(TAG, "busca falhou: " + e);
Log.w(TAG, "busca por anúncio falhou: " + e);
ouvinte = null;
r.achou(null);
semAnuncio();
return;
}
principal.postDelayed(new Runnable() {
@Override public void run() { terminar(null); }
}, LIMITE);
@Override public void run() { fimDoAnuncio(null); }
}, LIMITE_ANUNCIO);
}
@SuppressWarnings("deprecation")
@@ -67,7 +87,7 @@ final class Descoberta {
if (s.getHost() == null || s.getHost().isLoopbackAddress()) return;
String ip = s.getHost().getHostAddress();
if (ip == null || ip.contains(":")) return; // só IPv4: o servidor ouve em 0.0.0.0
terminar("http://" + ip + ":" + s.getPort());
fimDoAnuncio("http://" + ip + ":" + s.getPort());
}
});
} catch (Exception e) {
@@ -75,19 +95,93 @@ final class Descoberta {
}
}
private void terminar(final String endereco) {
/** Fim da busca por anúncio: achou responde; não achou passa para a varredura. */
private void fimDoAnuncio(final String endereco) {
principal.post(new Runnable() {
@Override public void run() {
if (ouvinte == null) return; // já respondeu
if (ouvinte == null) return; // já terminou
try { nsd.stopServiceDiscovery(ouvinte); } catch (Exception e) { /* já parou */ }
ouvinte = null;
Resposta r = resposta;
resposta = null;
Log.i(TAG, endereco == null ? "placa não encontrada na rede" : "placa encontrada: " + endereco);
if (r != null) r.achou(endereco);
if (endereco != null) { Log.i(TAG, "placa achada pelo anúncio: " + endereco); responder(endereco); }
else semAnuncio();
}
});
}
void parar() { terminar(null); }
private void semAnuncio() {
new Thread(new Runnable() {
@Override public void run() {
final String achado = varrer();
Log.i(TAG, achado == null ? "placa não encontrada na rede" : "placa achada na varredura: " + achado);
principal.post(new Runnable() { @Override public void run() { responder(achado); } });
}
}, "procura-placa").start();
}
private void responder(String endereco) {
ocupada = false;
Resposta r = resposta;
resposta = null;
if (r != null) r.achou(endereco);
}
/** Pergunta a cada endereço da rede /24 do celular (em paralelo) quem é o servidor do Carinha. */
private static String varrer() {
final List<String> alvos = new ArrayList<String>();
for (InetAddress meu : enderecosLocais()) {
byte[] b = meu.getAddress();
String prefixo = (b[0] & 255) + "." + (b[1] & 255) + "." + (b[2] & 255) + ".";
for (int i = 1; i < 255; i++) {
if (i != (b[3] & 255)) alvos.add(prefixo + i);
}
}
if (alvos.isEmpty()) return null;
final AtomicReference<String> achado = new AtomicReference<String>();
ExecutorService fios = Executors.newFixedThreadPool(32);
for (final String ip : alvos) {
fios.execute(new Runnable() {
@Override public void run() {
if (achado.get() == null && ehCarinha(ip)) achado.compareAndSet(null, "http://" + ip + ":" + PORTA);
}
});
}
fios.shutdown();
try { fios.awaitTermination(20, TimeUnit.SECONDS); } catch (InterruptedException e) { /* segue */ }
fios.shutdownNow();
return achado.get();
}
private static boolean ehCarinha(String ip) {
HttpURLConnection c = null;
try {
c = (HttpURLConnection) new URL("http://" + ip + ":" + PORTA + "/app/health").openConnection();
c.setConnectTimeout(600);
c.setReadTimeout(1500);
if (c.getResponseCode() != 200) return false;
byte[] buf = new byte[256];
int n;
try (InputStream in = c.getInputStream()) { n = in.read(buf); }
return n > 0 && new String(buf, 0, n, "UTF-8").contains("carinha-pc");
} catch (Exception e) {
return false;
} finally {
if (c != null) c.disconnect();
}
}
/** IPv4 privados das redes do celular (Wi-Fi), sem loopback nem dados móveis de operadora. */
private static List<InetAddress> enderecosLocais() {
List<InetAddress> lista = new ArrayList<InetAddress>();
try {
for (NetworkInterface ni : Collections.list(NetworkInterface.getNetworkInterfaces())) {
if (!ni.isUp() || ni.isLoopback() || ni.getName().startsWith("rmnet")) continue;
for (InetAddress a : Collections.list(ni.getInetAddresses())) {
if (a instanceof Inet4Address && a.isSiteLocalAddress()) lista.add(a);
}
}
} catch (Exception e) {
Log.w(TAG, "sem endereços locais: " + e);
}
return lista;
}
}