diff --git a/apk/controller/app/src/main/AndroidManifest.xml b/apk/controller/app/src/main/AndroidManifest.xml index c46df93..aa5905a 100644 --- a/apk/controller/app/src/main/AndroidManifest.xml +++ b/apk/controller/app/src/main/AndroidManifest.xml @@ -73,6 +73,8 @@ + + diff --git a/apk/controller/app/src/main/java/org/iiab/controller/BatteryUtils.java b/apk/controller/app/src/main/java/org/iiab/controller/BatteryUtils.java new file mode 100644 index 0000000..7deded9 --- /dev/null +++ b/apk/controller/app/src/main/java/org/iiab/controller/BatteryUtils.java @@ -0,0 +1,84 @@ +package org.iiab.controller; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.PowerManager; +import android.provider.Settings; +import androidx.activity.result.ActivityResultLauncher; +import androidx.appcompat.app.AlertDialog; + +public class BatteryUtils { + + // Previously at MainActivity + public static void checkAndPromptOptimizations(Activity activity, ActivityResultLauncher launcher) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PowerManager pm = (PowerManager) activity.getSystemService(Context.POWER_SERVICE); + if (pm != null && !pm.isIgnoringBatteryOptimizations(activity.getPackageName())) { + String manufacturer = Build.MANUFACTURER.toLowerCase(); + String message = activity.getString(R.string.battery_opt_msg); + + if (manufacturer.contains("oppo") || manufacturer.contains("realme") || manufacturer.contains("xiaomi")) { + + if (manufacturer.contains("oppo") || manufacturer.contains("realme")) { + message += activity.getString(R.string.battery_opt_oppo_extra); + } else if (manufacturer.contains("xiaomi")) { + message += activity.getString(R.string.battery_opt_xiaomi_extra); + } + + new AlertDialog.Builder(activity) + .setTitle(R.string.battery_opt_title) + .setMessage(message) + .setPositiveButton(R.string.go_to_settings, (dialog, which) -> openBatterySettings(activity, manufacturer)) + .setNegativeButton(R.string.cancel, null) + .show(); + } else { + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + intent.setData(Uri.parse("package:" + activity.getPackageName())); + launcher.launch(intent); + } + } + } + } + + private static void openBatterySettings(Activity activity, String manufacturer) { + boolean success = false; + String packageName = activity.getPackageName(); + + if (manufacturer.contains("oppo") || manufacturer.contains("realme")) { + try { + Intent intent = new Intent(); + intent.setComponent(new ComponentName("com.coloros.safecenter", "com.coloros.safecenter.permission.startup.StartupAppListActivity")); + activity.startActivity(intent); + success = true; + } catch (Exception e) { + try { + Intent intent = new Intent(); + intent.setComponent(new ComponentName("com.coloros.oppoguardelf", "com.coloros.oppoguardelf.Permission.BackgroundAllowAppListActivity")); + activity.startActivity(intent); + success = true; + } catch (Exception e2) {} + } + } else if (manufacturer.contains("xiaomi")) { + try { + Intent intent = new Intent("miui.intent.action.APP_BATTERY_SAVER_SETTINGS"); + intent.setComponent(new ComponentName("com.miui.powerkeeper", "com.miui.powerkeeper.ui.HiddenAppsConfigActivity")); + intent.putExtra("package_name", packageName); + intent.putExtra("package_label", activity.getString(R.string.app_name)); + activity.startActivity(intent); + success = true; + } catch (Exception e) {} + } + + if (!success) { + try { + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + intent.setData(Uri.parse("package:" + packageName)); + activity.startActivity(intent); + } catch (Exception ex) {} + } + } +} \ No newline at end of file diff --git a/apk/controller/app/src/main/java/org/iiab/controller/BiometricHelper.java b/apk/controller/app/src/main/java/org/iiab/controller/BiometricHelper.java new file mode 100644 index 0000000..af35270 --- /dev/null +++ b/apk/controller/app/src/main/java/org/iiab/controller/BiometricHelper.java @@ -0,0 +1,70 @@ +package org.iiab.controller; + +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.provider.Settings; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.biometric.BiometricManager; +import androidx.biometric.BiometricPrompt; +import androidx.core.content.ContextCompat; + +import java.util.concurrent.Executor; + +public class BiometricHelper { + + // This is the "phone line" that tells MainActivity the user succeeded + public interface AuthCallback { + void onSuccess(); + } + + public static boolean isDeviceSecure(Context context) { + BiometricManager bm = BiometricManager.from(context); + int auth = BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + auth = BiometricManager.Authenticators.BIOMETRIC_WEAK | BiometricManager.Authenticators.DEVICE_CREDENTIAL; + } + android.app.KeyguardManager km = (android.app.KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); + + return bm.canAuthenticate(auth) == BiometricManager.BIOMETRIC_SUCCESS || (km != null && km.isDeviceSecure()); + } + + public static void showEnrollmentDialog(Context context) { + new AlertDialog.Builder(context) + .setTitle(R.string.security_required_title) + .setMessage(R.string.security_required_msg) + .setPositiveButton(R.string.go_to_settings, (dialog, which) -> { + Intent intent = new Intent(Settings.ACTION_SECURITY_SETTINGS); + context.startActivity(intent); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + public static void prompt(AppCompatActivity activity, String title, String subtitle, AuthCallback callback) { + Executor executor = ContextCompat.getMainExecutor(activity); + BiometricPrompt biometricPrompt = new BiometricPrompt(activity, executor, new BiometricPrompt.AuthenticationCallback() { + @Override + public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) { + super.onAuthenticationSucceeded(result); + // Call back to MainActivity! + callback.onSuccess(); + } + }); + + int auth = BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + auth = BiometricManager.Authenticators.BIOMETRIC_WEAK | BiometricManager.Authenticators.DEVICE_CREDENTIAL; + } + + BiometricPrompt.PromptInfo promptInfo = new BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setAllowedAuthenticators(auth) + .build(); + + biometricPrompt.authenticate(promptInfo); + } +} \ No newline at end of file diff --git a/apk/controller/app/src/main/java/org/iiab/controller/DashboardManager.java b/apk/controller/app/src/main/java/org/iiab/controller/DashboardManager.java new file mode 100644 index 0000000..a34828f --- /dev/null +++ b/apk/controller/app/src/main/java/org/iiab/controller/DashboardManager.java @@ -0,0 +1,99 @@ +package org.iiab.controller; + +import android.app.Activity; +import android.content.Intent; +import android.provider.Settings; +import android.transition.AutoTransition; +import android.transition.TransitionManager; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; + +public class DashboardManager { + + private final Activity activity; + private final LinearLayout dashboardContainer; + + private final View dashWifi, dashHotspot, dashTunnel; + private final View ledWifi, ledHotspot, ledTunnel; + private final View standaloneEspwButton; + private final View standaloneEspwDescription; + + // We pass a Callback so the Dashboard can tell MainActivity to start/stop the VPN + public interface DashboardActionCallback { + void onToggleEspwRequested(); + } + + public DashboardManager(Activity activity, View rootView, DashboardActionCallback callback) { + this.activity = activity; + + // Bind all the views + dashboardContainer = (LinearLayout) rootView.findViewById(R.id.dashboard_container); + dashWifi = rootView.findViewById(R.id.dash_wifi); + dashHotspot = rootView.findViewById(R.id.dash_hotspot); + dashTunnel = rootView.findViewById(R.id.dash_tunnel); + + ledWifi = rootView.findViewById(R.id.led_wifi); + ledHotspot = rootView.findViewById(R.id.led_hotspot); + ledTunnel = rootView.findViewById(R.id.led_tunnel); + + standaloneEspwButton = rootView.findViewById(R.id.control); + standaloneEspwDescription = rootView.findViewById(R.id.control_description); + + setupListeners(callback); + } + + private void setupListeners(DashboardActionCallback callback) { + // Single tap opens Settings directly (No wrench icons needed!) + dashWifi.setOnClickListener(v -> activity.startActivity(new Intent(Settings.ACTION_WIFI_SETTINGS))); + + dashHotspot.setOnClickListener(v -> { + try { + Intent intent = new Intent(Intent.ACTION_MAIN); + intent.setClassName("com.android.settings", "com.android.settings.TetherSettings"); + activity.startActivity(intent); + } catch (Exception e) { + activity.startActivity(new Intent(Settings.ACTION_WIRELESS_SETTINGS)); + } + }); + + // The Tunnel/ESPW toggle logic + View.OnClickListener toggleEspw = v -> callback.onToggleEspwRequested(); + standaloneEspwButton.setOnClickListener(toggleEspw); + dashTunnel.setOnClickListener(toggleEspw); + } + + // Updates the LED graphics based on actual OS connectivity states + public void updateConnectivityLeds(boolean isWifiOn, boolean isHotspotOn) { + ledWifi.setBackgroundResource(isWifiOn ? R.drawable.led_on_green : R.drawable.led_off); + ledHotspot.setBackgroundResource(isHotspotOn ? R.drawable.led_on_green : R.drawable.led_off); + } + + // The Magic Morphing Animation! + public void setTunnelState(boolean isTunnelActive, boolean isDegraded) { + // Tells Android to smoothly animate any layout changes we make next + TransitionManager.beginDelayedTransition((ViewGroup) dashboardContainer.getParent(), new AutoTransition().setDuration(300)); + + if (isTunnelActive) { + // Morph into 33% / 33% / 33% Dashboard mode + standaloneEspwButton.setVisibility(View.GONE); + standaloneEspwDescription.setVisibility(View.GONE); + dashTunnel.setVisibility(View.VISIBLE); + ledTunnel.setBackgroundResource(isDegraded ? R.drawable.led_on_orange : R.drawable.led_on_green); + + // Force recalculate + dashboardContainer.setWeightSum(3f); + } else { + // Morph back into 50% / 50% mode + dashTunnel.setVisibility(View.GONE); + standaloneEspwButton.setVisibility(View.VISIBLE); + standaloneEspwDescription.setVisibility(View.VISIBLE); + // The LED turns off implicitly since the whole dash_tunnel hides, but we can enforce it: + ledTunnel.setBackgroundResource(R.drawable.led_off); + // Force recalculate + dashboardContainer.setWeightSum(2f); + } + // Force recalculate + dashboardContainer.requestLayout(); + } +} diff --git a/apk/controller/app/src/main/java/org/iiab/controller/LogManager.java b/apk/controller/app/src/main/java/org/iiab/controller/LogManager.java new file mode 100644 index 0000000..c3a1f8e --- /dev/null +++ b/apk/controller/app/src/main/java/org/iiab/controller/LogManager.java @@ -0,0 +1,84 @@ +package org.iiab.controller; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Locale; + +public class LogManager { + private static final String LOG_FILE_NAME = "watchdog_heartbeat_log.txt"; + + // Callbacks to communicate with MainActivity + public interface LogReadCallback { + void onResult(String logContent, boolean isRapidGrowth); + } + + public interface LogClearCallback { + void onSuccess(); + void onError(String message); + } + + // Read the file in the background and return the result to the main thread + public static void readLogsAsync(Context context, LogReadCallback callback) { + new Thread(() -> { + File logFile = new File(context.getFilesDir(), LOG_FILE_NAME); + StringBuilder sb = new StringBuilder(); + + if (!logFile.exists()) { + sb.append(context.getString(R.string.no_blackbox_found)).append("\n"); + } else { + sb.append(context.getString(R.string.loading_history)).append("\n"); + try (BufferedReader br = new BufferedReader(new FileReader(logFile))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append("\n"); + } + } catch (IOException e) { + sb.append(context.getString(R.string.error_reading_history, e.getMessage())).append("\n"); + } + sb.append(context.getString(R.string.end_of_history)).append("\n"); + } + + SharedPreferences internalPrefs = context.getSharedPreferences("IIAB_Internal", Context.MODE_PRIVATE); + boolean isRapid = internalPrefs.getBoolean(IIABWatchdog.PREF_RAPID_GROWTH, false); + String result = sb.toString(); + + // We return the call on the main UI thread + new Handler(Looper.getMainLooper()).post(() -> callback.onResult(result, isRapid)); + }).start(); + } + + // Delete the file securely + public static void clearLogs(Context context, LogClearCallback callback) { + File logFile = new File(context.getFilesDir(), LOG_FILE_NAME); + try (PrintWriter pw = new PrintWriter(logFile)) { + pw.print(""); + context.getSharedPreferences("IIAB_Internal", Context.MODE_PRIVATE) + .edit().putBoolean(IIABWatchdog.PREF_RAPID_GROWTH, false).apply(); + callback.onSuccess(); + } catch (IOException e) { + callback.onError(e.getMessage()); + } + } + + // Calculate the file size + public static String getFormattedSize(Context context) { + File logFile = new File(context.getFilesDir(), LOG_FILE_NAME); + long size = logFile.exists() ? logFile.length() : 0; + + if (size < 1024) { + return size + " B"; + } else if (size < 1024 * 1024) { + return String.format(Locale.getDefault(), "%.1f KB", size / 1024.0); + } else { + return String.format(Locale.getDefault(), "%.2f MB", size / (1024.0 * 1024.0)); + } + } +} \ No newline at end of file diff --git a/apk/controller/app/src/main/java/org/iiab/controller/MainActivity.java b/apk/controller/app/src/main/java/org/iiab/controller/MainActivity.java index d902e60..2d6fc35 100644 --- a/apk/controller/app/src/main/java/org/iiab/controller/MainActivity.java +++ b/apk/controller/app/src/main/java/org/iiab/controller/MainActivity.java @@ -45,7 +45,10 @@ import android.text.method.ScrollingMovementMethod; import android.os.Build; import android.os.Handler; import android.os.PowerManager; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; import android.provider.Settings; +import android.net.wifi.WifiManager; import androidx.annotation.NonNull; import androidx.biometric.BiometricManager; @@ -67,6 +70,8 @@ import java.text.SimpleDateFormat; import java.util.concurrent.Executor; import java.net.HttpURLConnection; import java.net.URL; +import java.net.Proxy; +import java.net.InetSocketAddress; public class MainActivity extends AppCompatActivity implements View.OnClickListener { private static final String TAG = "IIAB-MainActivity"; @@ -105,6 +110,19 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe private TextView versionFooter; private ProgressBar logProgress; + // Cassette Deck UI + private LinearLayout deckContainer; + private Button btnServerControl; + private ObjectAnimator fusionAnimator; + private android.animation.ObjectAnimator exploreAnimator; + private boolean isServerAlive = false; + private boolean isNegotiating = false; + private boolean isProxyDegraded = false; + private String currentTargetUrl = null; + private long pulseStartTime = 0; + + private DashboardManager dashboardManager; + private ActivityResultLauncher vpnPermissionLauncher; private ActivityResultLauncher requestPermissionsLauncher; private ActivityResultLauncher batteryOptLauncher; @@ -116,18 +134,44 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe // Variables for adaptive localhost server check private final Handler serverCheckHandler = new Handler(android.os.Looper.getMainLooper()); private Runnable serverCheckRunnable; - private static final int MIN_CHECK_INTERVAL = 5000; // 5 seconds floor - private static final int MAX_CHECK_INTERVAL = 60000; // 60 seconds ceiling - private int currentCheckInterval = MIN_CHECK_INTERVAL; + private static final int CHECK_INTERVAL_MS = 3000; private final BroadcastReceiver logReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { - if (IIABWatchdog.ACTION_LOG_MESSAGE.equals(intent.getAction())) { + String action = intent.getAction(); + + if (IIABWatchdog.ACTION_LOG_MESSAGE.equals(action)) { String message = intent.getStringExtra(IIABWatchdog.EXTRA_MESSAGE); addToLog(message); updateLogSizeUI(); } + else if (WatchdogService.ACTION_STATE_STARTED.equals(action)) { + // Calculate where we are in the 1200ms cycle (600ms down + 600ms up) + long elapsed = System.currentTimeMillis() - pulseStartTime; + long fullCycle = 1200; + + // Find out how many milliseconds are left to finish the current wave + long remainder = elapsed % fullCycle; + long timeToNextCycleEnd = fullCycle - remainder; + + // If the remaining time is too fast (< 1 second), add one more full cycle + // so the user actually has time to see the system notification drop down gracefully. + if (timeToNextCycleEnd < 1000) { + timeToNextCycleEnd += fullCycle; + } + + // Wait exactly until the wave hits 1.0f alpha, then lock it! + new Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { + finalizeEntryPulse(); + }, timeToNextCycleEnd); + } + else if (WatchdogService.ACTION_STATE_STOPPED.equals(action)) { + // Service is down! Give it a 1.5 second visual margin, then stop the exit pulse. + new Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { + finalizeExitPulse(); + }, 1500); + } } }; @@ -153,7 +197,7 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe if (result.getResultCode() == RESULT_OK && prefs.getEnable()) { connectVpn(); } - checkBatteryOptimizations(); + BatteryUtils.checkAndPromptOptimizations(MainActivity.this, batteryOptLauncher); } ); @@ -161,7 +205,7 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe new ActivityResultContracts.StartActivityForResult(), result -> { Log.d(TAG, "Returned from the battery settings screen"); - checkBatteryOptimizations(); + BatteryUtils.checkAndPromptOptimizations(MainActivity.this, batteryOptLauncher); } ); @@ -217,8 +261,25 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe advConfigLabel = findViewById(R.id.adv_config_label); logLabel = findViewById(R.id.log_label); + deckContainer = findViewById(R.id.deck_container); + btnServerControl = findViewById(R.id.btn_server_control); + + dashboardManager = new DashboardManager(this, findViewById(android.R.id.content), () -> { + handleControlClick(); + }); + // Listeners - watchdogControl.setOnClickListener(this); + watchdogControl.setOnClickListener(v -> { + boolean willBeEnabled = !prefs.getWatchdogEnable(); + if (willBeEnabled) { + BiometricHelper.prompt(MainActivity.this, + getString(R.string.unlock_watchdog_title), + getString(R.string.unlock_watchdog_subtitle), + () -> setWatchdogState(true)); + } else { + setWatchdogState(false); + } + }); btnClearLog.setOnClickListener(this); btnCopyLog.setOnClickListener(this); themeToggle.setOnClickListener(v -> toggleTheme()); @@ -231,12 +292,38 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe checkbox_global.setOnClickListener(this); button_apps.setOnClickListener(this); button_save.setOnClickListener(this); - button_control.setOnClickListener(this); + + btnServerControl.setOnClickListener(v -> { + // We're simplifying the action for now to test + if (btnServerControl.getText().toString().contains("Launch")) { + btnServerControl.setText("Starting..."); + btnServerControl.setAlpha(0.7f); // Efecto visual de "Cargando" + startTermuxEnvironmentVisible("--start"); + } else { + // TODO: We'll add the VPN biometric validation here later + btnServerControl.setText("Stopping..."); + btnServerControl.setAlpha(0.7f); + startTermuxEnvironmentVisible("--stop"); + + // Automatically turn off the Watchdog if we take down the server. + if (prefs.getWatchdogEnable()) setWatchdogState(false); + } + }); // Logic to open the WebView (PortalActivity) +// button_browse_content.setOnClickListener(v -> { +// Intent intent = new Intent(MainActivity.this, PortalActivity.class); +// // We tell the Portal exactly where to go +// String urlToLoad = prefs.getEnable() ? "http://box/home" : "http://localhost:8085/home"; +// intent.putExtra("TARGET_URL", urlToLoad); +// startActivity(intent); +// }); button_browse_content.setOnClickListener(v -> { - Intent intent = new Intent(MainActivity.this, PortalActivity.class); - startActivity(intent); + if (currentTargetUrl != null) { + Intent intent = new Intent(MainActivity.this, PortalActivity.class); + intent.putExtra("TARGET_URL", currentTargetUrl); + startActivity(intent); + } }); connectionLog.setMovementMethod(new ScrollingMovementMethod()); @@ -267,7 +354,8 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe @Override public void run() { checkServerStatus(); - serverCheckHandler.postDelayed(this, currentCheckInterval); + updateConnectivityStatus(); // Check Wi-Fi & Hotspot states + serverCheckHandler.postDelayed(this, CHECK_INTERVAL_MS); } }; serverCheckHandler.post(serverCheckRunnable); @@ -276,7 +364,7 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe private void showBatterySnackbar() { View rootView = findViewById(android.R.id.content); Snackbar.make(rootView, R.string.battery_opt_denied, Snackbar.LENGTH_INDEFINITE) - .setAction(R.string.fix_action, v -> checkBatteryOptimizations()) + .setAction(R.string.fix_action, v -> BatteryUtils.checkAndPromptOptimizations(MainActivity.this, batteryOptLauncher)) .show(); } @@ -298,20 +386,104 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe } } + private boolean pingUrl(String urlStr, boolean useProxy) { + try { + URL url = new URL(urlStr); + HttpURLConnection conn; + + if (useProxy) { + // We routed the request directly to the app's SOCKS proxy + int socksPort = prefs.getSocksPort(); // generally 1080 + Proxy proxy = new Proxy(Proxy.Type.SOCKS, new InetSocketAddress("127.0.0.1", socksPort)); + conn = (HttpURLConnection) url.openConnection(proxy); + } else { + // Normal request (for localhost) + conn = (HttpURLConnection) url.openConnection(); + } + + conn.setUseCaches(false); + conn.setConnectTimeout(1500); + conn.setReadTimeout(1500); + conn.setRequestMethod("GET"); + return (conn.getResponseCode() >= 200 && conn.getResponseCode() < 400); + } catch (Exception e) { + return false; + } + } + + private void runNegotiationSequence() { + isNegotiating = true; + runOnUiThread(() -> { + startExplorePulse(); // The orange button starts to beat. + updateUIColorsAndVisibility(); // We forced an immediate visual update + }); + + new Thread(() -> { + boolean boxAlive = false; + + // Attempt 1 (0 seconds) + boxAlive = pingUrl("http://box/home", true); + + // Attempt 2 (At 2 seconds) + if (!boxAlive) { + try { Thread.sleep(2000); } catch (InterruptedException ignored) {} + boxAlive = pingUrl("http://box/home", true); + } + + // Attempt 3 (At 3 seconds) + if (!boxAlive) { + try { Thread.sleep(1000); } catch (InterruptedException ignored) {} + boxAlive = pingUrl("http://box/home", true); + } + + // We validate if localhost serves as a fallback. + boolean localAlive = pingUrl("http://localhost:8085/home", false); + + // We evaluate the results + isNegotiating = false; + isServerAlive = boxAlive || localAlive; + isProxyDegraded = !boxAlive && localAlive; // Tunnel on, but proxy dead + + if (boxAlive) { + currentTargetUrl = "http://box/home"; + } else if (localAlive) { + currentTargetUrl = "http://localhost:8085/home"; + } else { + currentTargetUrl = null; + } + + runOnUiThread(this::updateUIColorsAndVisibility); + }).start(); + } private void prepareVpn() { Intent intent = VpnService.prepare(MainActivity.this); if (intent != null) { vpnPermissionLauncher.launch(intent); } else { if (prefs.getEnable()) connectVpn(); - checkBatteryOptimizations(); + BatteryUtils.checkAndPromptOptimizations(MainActivity.this, batteryOptLauncher); } } private void handleLogToggle() { boolean isOpening = connectionLog.getVisibility() == View.GONE; if (isOpening) { - readBlackBoxLogs(); + if (isReadingLogs) return; + isReadingLogs = true; + if (logProgress != null) logProgress.setVisibility(View.VISIBLE); + + // We delegate the reading to the Manager + LogManager.readLogsAsync(this, (logContent, isRapidGrowth) -> { + if (connectionLog != null) { + connectionLog.setText(logContent); + scrollToBottom(); + } + if (logProgress != null) logProgress.setVisibility(View.GONE); + if (logWarning != null) logWarning.setVisibility(isRapidGrowth ? View.VISIBLE : View.GONE); + updateLogSizeUI(); + isReadingLogs = false; + }); + startLogSizeUpdates(); } else { stopLogSizeUpdates(); @@ -332,16 +504,8 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe private void updateLogSizeUI() { if (logSizeText == null) return; - File logFile = new File(getFilesDir(), "watchdog_heartbeat_log.txt"); - long size = logFile.exists() ? logFile.length() : 0; - String sizeStr; - if (size < 1024) { - sizeStr = size + " B"; - } else if (size < 1024 * 1024) { - sizeStr = String.format(Locale.getDefault(), "%.1f KB", size / 1024.0); - } else { - sizeStr = String.format(Locale.getDefault(), "%.2f MB", size / (1024.0 * 1024.0)); - } + // The LogManager class does the calculation + String sizeStr = LogManager.getFormattedSize(this); logSizeText.setText(getString(R.string.log_size_format, sizeStr)); } @@ -351,51 +515,6 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe addToLog(getString(R.string.vpn_permission_granted)); } - private void readBlackBoxLogs() { - if (isReadingLogs) return; - isReadingLogs = true; - if (logProgress != null) logProgress.setVisibility(View.VISIBLE); - - new Thread(() -> { - File logFile = new File(getFilesDir(), "watchdog_heartbeat_log.txt"); - StringBuilder sb = new StringBuilder(); - - if (!logFile.exists()) { - sb.append(getString(R.string.no_blackbox_found)).append("\n"); - } else { - sb.append(getString(R.string.loading_history)).append("\n"); - try (BufferedReader br = new BufferedReader(new FileReader(logFile))) { - String line; - while ((line = br.readLine()) != null) { - sb.append(line).append("\n"); - } - } catch (IOException e) { - sb.append(getString(R.string.error_reading_history, e.getMessage())).append("\n"); - } - sb.append(getString(R.string.end_of_history)).append("\n"); - } - - final String result = sb.toString(); - SharedPreferences internalPrefs = getSharedPreferences("IIAB_Internal", Context.MODE_PRIVATE); - final boolean isRapid = internalPrefs.getBoolean(IIABWatchdog.PREF_RAPID_GROWTH, false); - - runOnUiThread(() -> { - if (connectionLog != null) { - connectionLog.setText(result); - scrollToBottom(); - } - if (logProgress != null) { - logProgress.setVisibility(View.GONE); - } - if (logWarning != null) { - logWarning.setVisibility(isRapid ? View.VISIBLE : View.GONE); - } - updateLogSizeUI(); - isReadingLogs = false; - }); - }).start(); - } - private void setVersionFooter() { try { PackageInfo pInfo = getPackageManager().getPackageInfo(getPackageName(), 0); @@ -424,6 +543,7 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe showBatterySnackbar(); } } + updateConnectivityStatus(); // Force instant UI refresh when returning to app if (getIntent() != null && getIntent().getBooleanExtra(VpnRecoveryReceiver.EXTRA_RECOVERY, false)) { addToLog(getString(R.string.recovery_pulse_received)); @@ -444,73 +564,6 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe setIntent(intent); } - private void checkBatteryOptimizations() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); - if (pm != null && !pm.isIgnoringBatteryOptimizations(getPackageName())) { - String manufacturer = Build.MANUFACTURER.toLowerCase(); - String message = getString(R.string.battery_opt_msg); - - if (manufacturer.contains("oppo") || manufacturer.contains("realme") || manufacturer.contains("xiaomi")) { - - if (manufacturer.contains("oppo") || manufacturer.contains("realme")) { - message += getString(R.string.battery_opt_oppo_extra); - } else if (manufacturer.contains("xiaomi")) { - message += getString(R.string.battery_opt_xiaomi_extra); - } - - new AlertDialog.Builder(this) - .setTitle(R.string.battery_opt_title) - .setMessage(message) - .setPositiveButton(R.string.go_to_settings, (dialog, which) -> openBatterySettings(manufacturer)) - .setNegativeButton(R.string.cancel, null) - .show(); - } else { - Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); - intent.setData(Uri.parse("package:" + getPackageName())); - batteryOptLauncher.launch(intent); - } - } - } - } - - private void openBatterySettings(String manufacturer) { - boolean success = false; - - if (manufacturer.contains("oppo") || manufacturer.contains("realme")) { - try { - Intent intent = new Intent(); - intent.setComponent(new ComponentName("com.coloros.safecenter", "com.coloros.safecenter.permission.startup.StartupAppListActivity")); - startActivity(intent); - success = true; - } catch (Exception e) { - try { - Intent intent = new Intent(); - intent.setComponent(new ComponentName("com.coloros.oppoguardelf", "com.coloros.oppoguardelf.Permission.BackgroundAllowAppListActivity")); - startActivity(intent); - success = true; - } catch (Exception e2) {} - } - } else if (manufacturer.contains("xiaomi")) { - try { - Intent intent = new Intent("miui.intent.action.APP_BATTERY_SAVER_SETTINGS"); - intent.setComponent(new ComponentName("com.miui.powerkeeper", "com.miui.powerkeeper.ui.HiddenAppsConfigActivity")); - intent.putExtra("package_name", getPackageName()); - intent.putExtra("package_label", getString(R.string.app_name)); - startActivity(intent); - success = true; - } catch (Exception e) {} - } - - if (!success) { - try { - Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - intent.setData(Uri.parse("package:" + getPackageName())); - startActivity(intent); - } catch (Exception ex) {} - } - } - private void toggleTheme() { SharedPreferences sharedPref = getPreferences(Context.MODE_PRIVATE); int currentMode = AppCompatDelegate.getDefaultNightMode(); @@ -543,7 +596,11 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe @Override protected void onStart() { super.onStart(); - IntentFilter filter = new IntentFilter(IIABWatchdog.ACTION_LOG_MESSAGE); + IntentFilter filter = new IntentFilter(); + filter.addAction(IIABWatchdog.ACTION_LOG_MESSAGE); + filter.addAction(WatchdogService.ACTION_STATE_STARTED); + filter.addAction(WatchdogService.ACTION_STATE_STOPPED); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver(logReceiver, filter, Context.RECEIVER_NOT_EXPORTED); } else { @@ -591,145 +648,106 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe } private void resetLogFile() { - File logFile = new File(getFilesDir(), "watchdog_heartbeat_log.txt"); - try (PrintWriter pw = new PrintWriter(logFile)) { - pw.print(""); - connectionLog.setText(""); - addToLog(getString(R.string.log_reset_user)); - getSharedPreferences("IIAB_Internal", Context.MODE_PRIVATE).edit().putBoolean(IIABWatchdog.PREF_RAPID_GROWTH, false).apply(); - if (logWarning != null) logWarning.setVisibility(View.GONE); - updateLogSizeUI(); - Toast.makeText(this, R.string.log_cleared_toast, Toast.LENGTH_SHORT).show(); - } catch (IOException e) { - Toast.makeText(this, getString(R.string.failed_reset_log, e.getMessage()), Toast.LENGTH_SHORT).show(); - } + LogManager.clearLogs(this, new LogManager.LogClearCallback() { + @Override + public void onSuccess() { + connectionLog.setText(""); + addToLog(getString(R.string.log_reset_user)); + if (logWarning != null) logWarning.setVisibility(View.GONE); + updateLogSizeUI(); + Toast.makeText(MainActivity.this, R.string.log_cleared_toast, Toast.LENGTH_SHORT).show(); + } + + @Override + public void onError(String message) { + Toast.makeText(MainActivity.this, getString(R.string.failed_reset_log, message), Toast.LENGTH_SHORT).show(); + } + }); } private void handleWatchdogClick() { - toggleWatchdog(prefs.getWatchdogEnable()); + setWatchdogState(!prefs.getWatchdogEnable()); } - private void toggleWatchdog(boolean stop) { - prefs.setWatchdogEnable(!stop); + private void setWatchdogState(boolean enable) { + prefs.setWatchdogEnable(enable); Intent intent = new Intent(this, WatchdogService.class); - if (stop) { - stopService(intent); - addToLog(getString(R.string.watchdog_stopped)); - } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent.setAction(WatchdogService.ACTION_START)); - else startService(intent.setAction(WatchdogService.ACTION_START)); + + if (enable) { + intent.setAction(WatchdogService.ACTION_START); addToLog(getString(R.string.watchdog_started)); + if (isServerAlive) startFusionPulse(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent); + } else { + startService(intent); + } + } else { + addToLog(getString(R.string.watchdog_stopped)); + startExitPulse(); + stopService(intent); } + updateUI(); + updateUIColorsAndVisibility(isServerAlive); } private void handleControlClick() { - if (prefs.getEnable()) showBiometricPrompt(); - else { - BiometricManager bm = BiometricManager.from(this); - int auth = BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) auth = BiometricManager.Authenticators.BIOMETRIC_WEAK | BiometricManager.Authenticators.DEVICE_CREDENTIAL; - android.app.KeyguardManager km = (android.app.KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE); - if (bm.canAuthenticate(auth) == BiometricManager.BIOMETRIC_SUCCESS || (km != null && km.isDeviceSecure())) { + if (prefs.getEnable()) { + BiometricHelper.prompt(this, + getString(R.string.auth_required_title), + getString(R.string.auth_required_subtitle), + () -> { + addToLog(getString(R.string.auth_success_disconnect)); + toggleService(true); + }); + } else { + if (BiometricHelper.isDeviceSecure(this)) { addToLog(getString(R.string.user_initiated_conn)); toggleService(false); - } else showEnrollmentDialog(); - } - } - - private void showEnrollmentDialog() { - new AlertDialog.Builder(this) - .setTitle(R.string.security_required_title) - .setMessage(R.string.security_required_msg) - .setPositiveButton(R.string.go_to_settings, (dialog, which) -> { - Intent intent = new Intent(Settings.ACTION_SECURITY_SETTINGS); - startActivity(intent); - }) - .setNegativeButton(R.string.cancel, null) - .show(); - } - - private void showBiometricPrompt() { - Executor ex = ContextCompat.getMainExecutor(this); - BiometricPrompt bp = new BiometricPrompt(this, ex, new BiometricPrompt.AuthenticationCallback() { - @Override - public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) { - super.onAuthenticationSucceeded(result); - addToLog(getString(R.string.auth_success_disconnect)); - toggleService(true); + } else { + BiometricHelper.showEnrollmentDialog(this); } - }); - int auth = BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL; - bp.authenticate(new BiometricPrompt.PromptInfo.Builder().setTitle(getString(R.string.auth_required_title)).setSubtitle(getString(R.string.auth_required_subtitle)).setAllowedAuthenticators(auth).build()); + } } // --- Secure Advanced Settings Menu --- private void handleConfigToggle() { if (configLayout.getVisibility() == View.GONE) { - BiometricManager bm = BiometricManager.from(this); - int auth = BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - auth = BiometricManager.Authenticators.BIOMETRIC_WEAK | BiometricManager.Authenticators.DEVICE_CREDENTIAL; - } - android.app.KeyguardManager km = (android.app.KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE); - - if (bm.canAuthenticate(auth) == BiometricManager.BIOMETRIC_SUCCESS || (km != null && km.isDeviceSecure())) { - showConfigBiometricPrompt(); + if (BiometricHelper.isDeviceSecure(this)) { + BiometricHelper.prompt(this, + getString(R.string.auth_required_title), + getString(R.string.auth_required_subtitle), + () -> toggleVisibility(configLayout, configLabel, getString(R.string.advanced_settings_label))); } else { - showEnrollmentDialog(); + BiometricHelper.showEnrollmentDialog(this); } } else { toggleVisibility(configLayout, configLabel, getString(R.string.advanced_settings_label)); } } - private void showConfigBiometricPrompt() { - Executor ex = ContextCompat.getMainExecutor(this); - BiometricPrompt bp = new BiometricPrompt(this, ex, new BiometricPrompt.AuthenticationCallback() { - @Override - public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) { - super.onAuthenticationSucceeded(result); - toggleVisibility(configLayout, configLabel, getString(R.string.advanced_settings_label)); - } - }); - - int auth = BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - auth = BiometricManager.Authenticators.BIOMETRIC_WEAK | BiometricManager.Authenticators.DEVICE_CREDENTIAL; - } - - bp.authenticate(new BiometricPrompt.PromptInfo.Builder() - .setTitle(getString(R.string.auth_required_title)) - .setSubtitle(getString(R.string.auth_required_subtitle)) - .setAllowedAuthenticators(auth) - .build()); - } - - private void showWatchdogBiometricPrompt() { - Executor ex = ContextCompat.getMainExecutor(this); - BiometricPrompt bp = new BiometricPrompt(this, new BiometricPrompt.AuthenticationCallback() { - @Override - public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) { - super.onAuthenticationSucceeded(result); - toggleWatchdog(true); - } - }); - int auth = BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL; - bp.authenticate(new BiometricPrompt.PromptInfo.Builder().setTitle(getString(R.string.unlock_watchdog_title)).setSubtitle(getString(R.string.unlock_watchdog_subtitle)).setAllowedAuthenticators(auth).build()); - } - private void toggleService(boolean stop) { prefs.setEnable(!stop); savePrefs(); - updateUI(); Intent intent = new Intent(this, TProxyService.class); startService(intent.setAction(stop ? TProxyService.ACTION_DISCONNECT : TProxyService.ACTION_CONNECT)); addToLog(getString(stop ? R.string.vpn_stopping : R.string.vpn_starting)); + + if (!stop) { + runNegotiationSequence(); + } else { + updateUIColorsAndVisibility(); + } } private void updateUI() { boolean vpnActive = prefs.getEnable(); boolean watchdogActive = prefs.getWatchdogEnable(); + + if (dashboardManager != null) dashboardManager.setTunnelState(vpnActive, isProxyDegraded); + if (vpnActive) { button_control.setText(R.string.control_disable); button_control.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_vpn_on)); @@ -739,10 +757,8 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe } if (watchdogActive) { watchdogControl.setText(R.string.watchdog_disable); - watchdogControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_watchdog_on)); } else { watchdogControl.setText(R.string.watchdog_enable); - watchdogControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_watchdog_off)); } edittext_socks_addr.setText(prefs.getSocksAddress()); edittext_socks_udp_addr.setText(prefs.getSocksUdpAddress()); @@ -769,54 +785,276 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe } private void checkServerStatus() { - new Thread(() -> { - boolean isReachable = false; - try { - URL url = new URL("http://localhost:8085/home"); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - connection.setConnectTimeout(1500); - connection.setReadTimeout(1500); - connection.setRequestMethod("GET"); - int responseCode = connection.getResponseCode(); + if (isNegotiating) return; - isReachable = (responseCode >= 200 && responseCode < 400); - } catch (Exception e) { - isReachable = false; + new Thread(() -> { + boolean localAlive = pingUrl("http://localhost:8085/home", false); + boolean vpnOn = prefs.getEnable(); + boolean boxAlive = false; + + if (vpnOn) { + // The passive radar must also use the proxy to test the tunnel. + boxAlive = pingUrl("http://box/home", true); + isProxyDegraded = !boxAlive && localAlive; + } else { + isProxyDegraded = false; } - final boolean serverAlive = isReachable; - runOnUiThread(() -> updateUIColorsAndVisibility(serverAlive)); + isServerAlive = localAlive || boxAlive; + + if (vpnOn && boxAlive) { + currentTargetUrl = "http://box/home"; + } else if (localAlive) { + currentTargetUrl = "http://localhost:8085/home"; + } else { + currentTargetUrl = null; + } + + runOnUiThread(this::updateUIColorsAndVisibility); }).start(); } + private void updateUIColorsAndVisibility() { + boolean isVpnActive = prefs.getEnable(); + boolean isWatchdogOn = prefs.getWatchdogEnable(); + + // Draw island + if (dashboardManager != null) { + dashboardManager.setTunnelState(isVpnActive, isProxyDegraded); + } + + // Draw main button + if (isVpnActive) { + button_control.setText(R.string.control_disable); + button_control.setBackgroundTintList(ContextCompat.getColorStateList(this, isServerAlive ? R.color.btn_vpn_on : R.color.btn_vpn_on_dim)); + } else { + button_control.setText(R.string.control_enable); + button_control.setBackgroundTintList(ContextCompat.getColorStateList(this, isServerAlive ? R.color.btn_vpn_off : R.color.btn_vpn_off_dim)); + } + + // 3. Draw Explore Content button + if (!isServerAlive) { + // State 1: Stopped + stopExplorePulse(); + button_browse_content.setEnabled(false); + button_browse_content.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_explore_disabled)); + button_browse_content.setAlpha(1.0f); + button_browse_content.setTextColor(Color.parseColor("#888888")); // Texto grisáceo apagado + } else if (isNegotiating) { + // State 2: Negotiating + button_browse_content.setEnabled(true); + button_browse_content.setTextColor(Color.WHITE); + // (El latido ya maneja la opacidad al 100%) + } else { + stopExplorePulse(); + button_browse_content.setEnabled(true); + button_browse_content.setTextColor(Color.WHITE); + button_browse_content.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_explore_ready)); + + if (isVpnActive && !isProxyDegraded) { + // State 5: All good + button_browse_content.setAlpha(1.0f); + } else { + // State 2: local or state 4 + button_browse_content.setAlpha(0.6f); + } + } + + // FUSION LOGIC - Watchdog + btnServerControl.setAlpha(1.0f); + if (isServerAlive) { + btnServerControl.setText("🛑 Stop Server"); + if (isWatchdogOn) { + deckContainer.setBackgroundColor(Color.parseColor("#44FF9800")); + btnServerControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_watchdog_on)); + watchdogControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_watchdog_on)); + } else { + if (fusionAnimator == null || !fusionAnimator.isRunning()) deckContainer.setBackgroundColor(Color.TRANSPARENT); + btnServerControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_danger)); + watchdogControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_watchdog_off)); + } + } else { + deckContainer.setBackgroundColor(Color.TRANSPARENT); + btnServerControl.setText("🚀 Launch Server"); + btnServerControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_success)); + watchdogControl.setBackgroundTintList(ContextCompat.getColorStateList(this, isWatchdogOn ? R.color.btn_watchdog_on : R.color.btn_watchdog_off)); + } + } + private void updateUIColorsAndVisibility(boolean isServerAlive) { boolean isVpnActive = prefs.getEnable(); if (!isServerAlive) { - currentCheckInterval = MIN_CHECK_INTERVAL; - - button_control.setEnabled(false); // Disable ESPW click + button_control.setEnabled(false); // Disable ESPW click button_browse_content.setVisibility(View.GONE); + stopExplorePulse(); // We stop the animation if the server dies + if (isVpnActive) { button_control.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_vpn_on_dim)); } else { button_control.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_vpn_off_dim)); } } else { - currentCheckInterval = Math.min((int)(currentCheckInterval * 1.5), MAX_CHECK_INTERVAL); + button_control.setEnabled(true); // Enable ESPW click + button_browse_content.setVisibility(View.VISIBLE); - button_control.setEnabled(true); // Enable ESPW click - button_browse_content.setVisibility(View.VISIBLE); // Always visible if server is alive - - updateUI(); + // Heart rate and diluted color control if (isVpnActive) { - button_browse_content.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_explore_ready)); - } else { - button_browse_content.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_explore_disabled)); - } + startExplorePulse(); + } else { + stopExplorePulse(); + } + } + // --- THE FUSION LOGIC (CASSETTE DECK) --- + boolean isWatchdogOn = prefs.getWatchdogEnable(); + btnServerControl.setAlpha(1.0f); // Reset opacity + + if (isServerAlive) { + btnServerControl.setText("🛑 Stop Server"); + + if (isWatchdogOn) { + // FUSION: Server alive + Watchdog active + // DELETED the stopFusionPulse() from here so the animation can live! + deckContainer.setBackgroundColor(Color.parseColor("#44FF9800")); // Bright border/background + btnServerControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_watchdog_on)); + watchdogControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_watchdog_on)); + } else { + // Sever alive, without Watchdog + if (fusionAnimator == null || !fusionAnimator.isRunning()) { + deckContainer.setBackgroundColor(Color.TRANSPARENT); + } + btnServerControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_danger)); // Red + watchdogControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_watchdog_off)); + } + } else { + // Server offline + deckContainer.setBackgroundColor(Color.TRANSPARENT); + btnServerControl.setText("🚀 Launch Server"); + btnServerControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_success)); // Verde + watchdogControl.setBackgroundTintList(ContextCompat.getColorStateList(this, isWatchdogOn ? R.color.btn_watchdog_on : R.color.btn_watchdog_off)); } } + private void startFusionPulse() { + deckContainer.setBackgroundColor(Color.parseColor("#44FF9800")); + if (fusionAnimator != null && fusionAnimator.isRunning()) fusionAnimator.cancel(); + + pulseStartTime = System.currentTimeMillis(); + + // Pulses infinitely until the Service broadcast stops it + fusionAnimator = ObjectAnimator.ofFloat(deckContainer, "alpha", 1f, 0.4f); + fusionAnimator.setDuration(600); + fusionAnimator.setRepeatCount(ObjectAnimator.INFINITE); + fusionAnimator.setRepeatMode(ObjectAnimator.REVERSE); + fusionAnimator.start(); + } + + private void startExitPulse() { + if (fusionAnimator != null && fusionAnimator.isRunning()) fusionAnimator.cancel(); + + // A slower, deliberate pulse infinitely until the Service + 1.5s delay stops it + fusionAnimator = ObjectAnimator.ofFloat(deckContainer, "alpha", deckContainer.getAlpha(), 0.3f); + fusionAnimator.setDuration(800); + fusionAnimator.setRepeatCount(ObjectAnimator.INFINITE); + fusionAnimator.setRepeatMode(ObjectAnimator.REVERSE); + fusionAnimator.start(); + } + + private void startExplorePulse() { + button_browse_content.setAlpha(1.0f); // 100% Bright Orange + button_browse_content.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_explore_ready)); + + if (exploreAnimator == null) { + android.animation.PropertyValuesHolder scaleX = android.animation.PropertyValuesHolder.ofFloat(View.SCALE_X, 1.0f, 1.03f); + android.animation.PropertyValuesHolder scaleY = android.animation.PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.0f, 1.03f); + exploreAnimator = android.animation.ObjectAnimator.ofPropertyValuesHolder(button_browse_content, scaleX, scaleY); + exploreAnimator.setDuration(800); // 800ms per heartbeat + exploreAnimator.setRepeatCount(android.animation.ObjectAnimator.INFINITE); + exploreAnimator.setRepeatMode(android.animation.ObjectAnimator.REVERSE); + } + if (!exploreAnimator.isRunning()) exploreAnimator.start(); + } + + private void stopExplorePulse() { + if (exploreAnimator != null && exploreAnimator.isRunning()) { + exploreAnimator.cancel(); + } + // Restore to normal size + button_browse_content.setScaleX(1.0f); + button_browse_content.setScaleY(1.0f); + + // Diluted orange (ready, but waiting for the tunnel) + button_browse_content.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_explore_ready)); + button_browse_content.setAlpha(0.6f); + } + + private void finalizeEntryPulse() { + if (fusionAnimator != null) fusionAnimator.cancel(); + deckContainer.setAlpha(1f); // Lock solid instantly + } + + private void finalizeExitPulse() { + if (fusionAnimator != null) fusionAnimator.cancel(); + deckContainer.animate() + .alpha(1f) + .setDuration(300) + .withEndAction(() -> { + deckContainer.setBackgroundColor(Color.TRANSPARENT); + }) + .start(); + } + + private void startTermuxEnvironmentVisible(String actionFlag) { + Intent intent = new Intent(); + intent.setClassName("com.termux", "com.termux.app.RunCommandService"); + intent.setAction("com.termux.RUN_COMMAND"); + intent.putExtra("com.termux.RUN_COMMAND_PATH", "/data/data/com.termux/files/usr/bin/iiab-termux"); + intent.putExtra("com.termux.RUN_COMMAND_ARGUMENTS", new String[]{actionFlag}); + intent.putExtra("com.termux.RUN_COMMAND_WORKDIR", "/data/data/com.termux/files/home"); + intent.putExtra("com.termux.RUN_COMMAND_BACKGROUND", false); + intent.putExtra("com.termux.RUN_COMMAND_SESSION_ACTION", "0"); + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent); + } else { + startService(intent); + } + addToLog("Sent to Termux: " + actionFlag); + } catch (Exception e) { + addToLog("CRITICAL: Failed Termux Intent: " + e.getMessage()); + } + } + + private void updateConnectivityStatus() { + WifiManager wifiManager = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); + boolean isWifiOn = wifiManager != null && wifiManager.isWifiEnabled(); + boolean isHotspotOn = false; + + try { + // 1. Try standard reflection (Works on older Androids) + java.lang.reflect.Method method = wifiManager.getClass().getDeclaredMethod("isWifiApEnabled"); + method.setAccessible(true); + isHotspotOn = (Boolean) method.invoke(wifiManager); + } catch (Throwable e) { + // 2. Fallback for Android 10+: Check physical network interfaces + try { + java.util.Enumeration interfaces = java.net.NetworkInterface.getNetworkInterfaces(); + while (interfaces != null && interfaces.hasMoreElements()) { + java.net.NetworkInterface iface = interfaces.nextElement(); + String name = iface.getName(); + if ((name.startsWith("ap") || name.startsWith("swlan")) && iface.isUp()) { + isHotspotOn = true; + break; + } + } + } catch (Exception ex) {} + } + + // Let the Dashboard handle the LEDs! + if (dashboardManager != null) dashboardManager.updateConnectivityLeds(isWifiOn, isHotspotOn); + } + private void savePrefs() { prefs.setSocksAddress("127.0.0.1"); prefs.setSocksPort(1080); diff --git a/apk/controller/app/src/main/java/org/iiab/controller/PortalActivity.java b/apk/controller/app/src/main/java/org/iiab/controller/PortalActivity.java index 0971bf5..cd5a903 100644 --- a/apk/controller/app/src/main/java/org/iiab/controller/PortalActivity.java +++ b/apk/controller/app/src/main/java/org/iiab/controller/PortalActivity.java @@ -87,10 +87,19 @@ public class PortalActivity extends AppCompatActivity { Preferences prefs = new Preferences(this); boolean isVpnActive = prefs.getEnable(); - String targetUrl = isVpnActive ? "http://box/" : "http://localhost:8085/home"; + + String rawUrl = getIntent().getStringExtra("TARGET_URL"); + + // If for some strange reason the URL arrives empty, we use the security fallback + if (rawUrl == null || rawUrl.isEmpty()) { + rawUrl = "http://localhost:8085/home"; + } + + // 1. Damos alcance global seguro a la URL para todos los lambdas de aquí en adelante + final String finalTargetUrl = rawUrl; btnHome.setOnClickListener(v -> { - webView.loadUrl(targetUrl); + webView.loadUrl(finalTargetUrl); // Usamos la variable final resetTimer.run(); }); @@ -146,6 +155,22 @@ public class PortalActivity extends AppCompatActivity { // Restore cache for normal browsing speed view.getSettings().setCacheMode(android.webkit.WebSettings.LOAD_DEFAULT); } + + @Override + public void onReceivedError(WebView view, android.webkit.WebResourceRequest request, android.webkit.WebResourceError error) { + super.onReceivedError(view, request, error); + + if (request.isForMainFrame()) { + String customErrorHtml = "" + + "

⚠️ Connection Failed

" + + "

Unable to reach the secure environment.

" + + "

Error: " + error.getDescription() + "

" + + ""; + view.loadData(customErrorHtml, "text/html", "UTF-8"); + isPageLoading = false; + btnReload.setText("↻"); + } + } }); // --- MANUALLY CLOSE BAR LOGIC --- @@ -163,10 +188,10 @@ public class PortalActivity extends AppCompatActivity { int tempPort = prefs.getSocksPort(); if (tempPort <= 0) tempPort = 1080; - // Variable safe to read in lambda + // 3. Restauramos la variable segura para el puerto final int finalProxyPort = tempPort; - // 3. Proxy block (ONLY IF VPN IS ACTIVE) + // 4. Proxy block (ONLY IF VPN IS ACTIVE) if (isVpnActive) { if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) { ProxyConfig proxyConfig = new ProxyConfig.Builder() @@ -178,16 +203,16 @@ public class PortalActivity extends AppCompatActivity { ProxyController.getInstance().setProxyOverride(proxyConfig, executor, () -> { Log.d(TAG, "Proxy configured on port: " + finalProxyPort); // Load HTML only when proxy is ready - webView.loadUrl(targetUrl); + webView.loadUrl(finalTargetUrl); }); } else { // Fallback for older devices Log.w(TAG, "Proxy Override not supported"); - webView.loadUrl(targetUrl); + webView.loadUrl(finalTargetUrl); } } else { // VPN is OFF. Do NOT use proxy. Just load localhost directly. - webView.loadUrl(targetUrl); + webView.loadUrl(finalTargetUrl); } } diff --git a/apk/controller/app/src/main/java/org/iiab/controller/WatchdogService.java b/apk/controller/app/src/main/java/org/iiab/controller/WatchdogService.java index 12133e0..38b3eb0 100644 --- a/apk/controller/app/src/main/java/org/iiab/controller/WatchdogService.java +++ b/apk/controller/app/src/main/java/org/iiab/controller/WatchdogService.java @@ -21,7 +21,8 @@ public class WatchdogService extends Service { public static final String ACTION_START = "org.iiab.controller.WATCHDOG_START"; public static final String ACTION_STOP = "org.iiab.controller.WATCHDOG_STOP"; public static final String ACTION_HEARTBEAT = "org.iiab.controller.HEARTBEAT"; - + public static final String ACTION_STATE_STARTED = "org.iiab.controller.WATCHDOG_STARTED"; + public static final String ACTION_STATE_STOPPED = "org.iiab.controller.WATCHDOG_STOPPED"; private static final int HEARTBEAT_INTERVAL_MS = 20 * 1000; @Override @@ -36,12 +37,8 @@ public class WatchdogService extends Service { String action = intent.getAction(); if (ACTION_START.equals(action)) { startWatchdog(); - } else if (ACTION_STOP.equals(action)) { - stopWatchdog(); - return START_NOT_STICKY; } else if (ACTION_HEARTBEAT.equals(action)) { IIABWatchdog.performHeartbeat(this); - // CRITICAL: Reschedule for the next pulse to create an infinite loop scheduleHeartbeat(); } } @@ -58,15 +55,26 @@ public class WatchdogService extends Service { IIABWatchdog.logSessionStart(this); scheduleHeartbeat(); + + Intent startIntent = new Intent(ACTION_STATE_STARTED); + startIntent.setPackage(getPackageName()); + sendBroadcast(startIntent); } - private void stopWatchdog() { + @Override + public void onDestroy() { + // 1. Avisamos inmediatamente a la UI que nos estamos apagando + Intent stopIntent = new Intent(ACTION_STATE_STOPPED); + stopIntent.setPackage(getPackageName()); + sendBroadcast(stopIntent); + + // 2. Limpiamos la basura cancelHeartbeat(); IIABWatchdog.logSessionStop(this); stopForeground(true); - stopSelf(); - } + super.onDestroy(); + } private PendingIntent getHeartbeatPendingIntent() { Intent intent = new Intent(this, WatchdogService.class); intent.setAction(ACTION_HEARTBEAT); @@ -101,12 +109,6 @@ public class WatchdogService extends Service { } } - @Override - public void onDestroy() { - stopWatchdog(); - super.onDestroy(); - } - @Override public IBinder onBind(Intent intent) { return null; diff --git a/apk/controller/app/src/main/res/drawable/led_off.xml b/apk/controller/app/src/main/res/drawable/led_off.xml new file mode 100644 index 0000000..36cf1e9 --- /dev/null +++ b/apk/controller/app/src/main/res/drawable/led_off.xml @@ -0,0 +1,4 @@ + + + + diff --git a/apk/controller/app/src/main/res/drawable/led_on_green.xml b/apk/controller/app/src/main/res/drawable/led_on_green.xml new file mode 100644 index 0000000..ff41ef2 --- /dev/null +++ b/apk/controller/app/src/main/res/drawable/led_on_green.xml @@ -0,0 +1,4 @@ + + + + diff --git a/apk/controller/app/src/main/res/drawable/led_on_orange.xml b/apk/controller/app/src/main/res/drawable/led_on_orange.xml new file mode 100644 index 0000000..3137097 --- /dev/null +++ b/apk/controller/app/src/main/res/drawable/led_on_orange.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apk/controller/app/src/main/res/layout/main.xml b/apk/controller/app/src/main/res/layout/main.xml index 876b8a2..cc23211 100644 --- a/apk/controller/app/src/main/res/layout/main.xml +++ b/apk/controller/app/src/main/res/layout/main.xml @@ -68,8 +68,88 @@ android:layout_height="wrap_content" android:padding="16dp"> - - + + + + + + + + + + + + + + + + +