diff --git a/android/app/java/br/carinha/rosto/Descoberta.java b/android/app/java/br/carinha/rosto/Descoberta.java index 0e37fa1..c56359c 100644 --- a/android/app/java/br/carinha/rosto/Descoberta.java +++ b/android/app/java/br/carinha/rosto/Descoberta.java @@ -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 alvos = new ArrayList(); + 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 achado = new AtomicReference(); + 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 enderecosLocais() { + List lista = new ArrayList(); + 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; + } }