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 2d6fc35..530968e 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
@@ -112,12 +112,16 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
// Cassette Deck UI
private LinearLayout deckContainer;
- private Button btnServerControl;
+ private ProgressButton btnServerControl;
private ObjectAnimator fusionAnimator;
private android.animation.ObjectAnimator exploreAnimator;
private boolean isServerAlive = false;
private boolean isNegotiating = false;
private boolean isProxyDegraded = false;
+ private Boolean targetServerState = null;
+ private String serverTransitionText = "";
+ private final Handler timeoutHandler = new Handler(android.os.Looper.getMainLooper());
+ private Runnable timeoutRunnable;
private String currentTargetUrl = null;
private long pulseStartTime = 0;
@@ -271,14 +275,7 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
// Listeners
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);
- }
+ setWatchdogState(willBeEnabled);
});
btnClearLog.setOnClickListener(this);
btnCopyLog.setOnClickListener(this);
@@ -294,30 +291,42 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
button_save.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"
+ // Ignore clicks if we are already waiting for a state change
+ if (targetServerState != null) return;
+
+ // Freeze the transition text and define the TARGET state
+ serverTransitionText = !isServerAlive ? "Booting..." : "Shutting down...";
+ targetServerState = !isServerAlive;
+
+ // Lock the UI and start infinite animation
+ updateUIColorsAndVisibility();
+ btnServerControl.startProgress();
+
+ // Set a hard timeout (45 seconds) as a safety net
+ timeoutRunnable = () -> {
+ if (targetServerState != null) {
+ targetServerState = null; // Abort transition
+ btnServerControl.stopProgress();
+ updateUIColorsAndVisibility();
+ addToLog("Warning: Server state transition timed out.");
+ }
+ };
+ timeoutHandler.postDelayed(timeoutRunnable, 45000);
+
+ // Execute the corresponding script command
+ if (!isServerAlive) {
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);
+ // Turn off Watchdog gracefully when stopping the server manually
+ 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 -> {
if (currentTargetUrl != null) {
Intent intent = new Intent(MainActivity.this, PortalActivity.class);
@@ -442,7 +451,13 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
// We evaluate the results
isNegotiating = false;
isServerAlive = boxAlive || localAlive;
- isProxyDegraded = !boxAlive && localAlive; // Tunnel on, but proxy dead
+
+ // If VPN is ON but box/proxy is dead, the tunnel is degraded (Orange).
+ if (prefs.getEnable()) {
+ isProxyDegraded = !boxAlive;
+ } else {
+ isProxyDegraded = false;
+ }
if (boxAlive) {
currentTargetUrl = "http://box/home";
@@ -690,7 +705,7 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
}
updateUI();
- updateUIColorsAndVisibility(isServerAlive);
+ updateUIColorsAndVisibility();
}
private void handleControlClick() {
@@ -795,13 +810,20 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
if (vpnOn) {
// The passive radar must also use the proxy to test the tunnel.
boxAlive = pingUrl("http://box/home", true);
- isProxyDegraded = !boxAlive && localAlive;
+ isProxyDegraded = !boxAlive;
} else {
isProxyDegraded = false;
}
isServerAlive = localAlive || boxAlive;
+ // STATE MACHINE: Has the target state been reached?
+ if (targetServerState != null && isServerAlive == targetServerState) {
+ targetServerState = null; // Transition complete!
+ timeoutHandler.removeCallbacks(timeoutRunnable); // Cancel safety net
+ runOnUiThread(() -> btnServerControl.stopProgress()); // Unlock button
+ }
+
if (vpnOn && boxAlive) {
currentTargetUrl = "http://box/home";
} else if (localAlive) {
@@ -818,120 +840,90 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe
boolean isVpnActive = prefs.getEnable();
boolean isWatchdogOn = prefs.getWatchdogEnable();
- // Draw island
+ // Draw island (Tunnel LED colors)
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));
+ // Draw main VPN button (ESPW)
+ if (!isServerAlive) {
+ // Lock and dim the VPN button if there is no server to connect to
+ button_control.setEnabled(false);
+ if (isVpnActive) {
+ button_control.setText(R.string.control_disable);
+ button_control.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_vpn_on_dim));
+ } else {
+ button_control.setText(R.string.control_enable);
+ button_control.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_vpn_off_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));
+ // Unlock if server is alive
+ button_control.setEnabled(true);
+ if (isVpnActive) {
+ button_control.setText(R.string.control_disable);
+ button_control.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_vpn_on));
+ } else {
+ button_control.setText(R.string.control_enable);
+ button_control.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_vpn_off));
+ }
}
- // 3. Draw Explore Content button
+ // Draw Explore Content button
+ // Ensure it is ALWAYS visible, never GONE
+ button_browse_content.setVisibility(View.VISIBLE);
+
if (!isServerAlive) {
- // State 1: Stopped
+ // State 1: Stopped (Greyed out)
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
+ button_browse_content.setTextColor(Color.parseColor("#888888"));
} else if (isNegotiating) {
- // State 2: Negotiating
+ // State 3: Negotiating
button_browse_content.setEnabled(true);
button_browse_content.setTextColor(Color.WHITE);
- // (El latido ya maneja la opacidad al 100%)
} else {
+ // State: Alive
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) {
- 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 {
- button_control.setEnabled(true); // Enable ESPW click
- button_browse_content.setVisibility(View.VISIBLE);
-
- // Heart rate and diluted color control
- if (isVpnActive) {
+ button_browse_content.setAlpha(1.0f); // 100% Perfect state
startExplorePulse();
} else {
- stopExplorePulse();
+ button_browse_content.setAlpha(0.6f); // Watered down fallback state
}
}
- // --- 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));
- }
+ // FUSION LOGIC (Watchdog & Server Control)
+ if (targetServerState != null) {
+ // STATE: COOL-OFF (Locked)
+ btnServerControl.setAlpha(0.6f);
+ btnServerControl.setText(serverTransitionText);
+ btnServerControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_explore_disabled));
} 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));
+ // STATE: NORMAL (Unlocked)
+ 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));
+ }
}
}
diff --git a/apk/controller/app/src/main/java/org/iiab/controller/ProgressButton.java b/apk/controller/app/src/main/java/org/iiab/controller/ProgressButton.java
new file mode 100644
index 0000000..3a1bda7
--- /dev/null
+++ b/apk/controller/app/src/main/java/org/iiab/controller/ProgressButton.java
@@ -0,0 +1,147 @@
+package org.iiab.controller;
+
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.graphics.Path;
+import android.util.AttributeSet;
+
+import androidx.core.content.ContextCompat;
+import androidx.appcompat.widget.AppCompatButton;
+
+public class ProgressButton extends AppCompatButton {
+
+ private Paint progressPaint;
+ private Paint progressBackgroundPaint;
+
+ private int progressColor;
+ private int progressBackgroundColor;
+ private Path clipPath;
+ private RectF rectF;
+ private float cornerRadius;
+ private int progressHeight;
+
+ // Animation variables
+ private float currentProgress = 0f;
+ private boolean isRunning = false;
+ private ValueAnimator animator;
+
+ public ProgressButton(Context context) {
+ super(context);
+ init(context, null);
+ }
+
+ public ProgressButton(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init(context, attrs);
+ }
+
+ public ProgressButton(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(context, attrs);
+ }
+
+ private void init(Context context, AttributeSet attrs) {
+ if (attrs != null) {
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ProgressButton, 0, 0);
+ try {
+ progressColor = a.getColor(R.styleable.ProgressButton_progressButtonColor, ContextCompat.getColor(context, R.color.btn_danger));
+ progressBackgroundColor = a.getColor(R.styleable.ProgressButton_progressButtonBackgroundColor, Color.parseColor("#44888888"));
+ progressHeight = a.getDimensionPixelSize(R.styleable.ProgressButton_progressButtonHeight, (int) (6 * getResources().getDisplayMetrics().density));
+ // Note: We no longer read 'duration' from XML because the animation is infinite.
+ } finally {
+ a.recycle();
+ }
+ } else {
+ // Safe defaults
+ progressColor = ContextCompat.getColor(context, R.color.btn_danger);
+ progressBackgroundColor = Color.parseColor("#44888888");
+ progressHeight = (int) (6 * getResources().getDisplayMetrics().density);
+ }
+
+ progressPaint = new Paint();
+ progressPaint.setColor(progressColor);
+ progressPaint.setStyle(Paint.Style.FILL);
+
+ progressBackgroundPaint = new Paint();
+ progressBackgroundPaint.setColor(progressBackgroundColor);
+ progressBackgroundPaint.setStyle(Paint.Style.FILL);
+
+ // Initialize clipping path variables
+ clipPath = new Path();
+ rectF = new RectF();
+ cornerRadius = 8 * getResources().getDisplayMetrics().density;
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ // Draw the background and text first
+ super.onDraw(canvas);
+
+ // Draw the progress bar constrained by the button's rounded corners
+ if (progressHeight > 0 && isRunning) {
+ int buttonWidth = getWidth();
+ int buttonHeight = getHeight();
+
+ // Calculate width based on the current animated float (0.0f to 1.0f)
+ int progressWidth = (int) (buttonWidth * currentProgress);
+
+ // 1. Prepare the rounded mask (matches the button's bounds)
+ rectF.set(0, 0, buttonWidth, buttonHeight);
+ clipPath.reset();
+ clipPath.addRoundRect(rectF, cornerRadius, cornerRadius, Path.Direction.CW);
+
+ // 2. Save canvas state and apply the mask
+ canvas.save();
+ canvas.clipPath(clipPath);
+
+ // 3. Draw the tracks
+ canvas.drawRect(0, buttonHeight - progressHeight, buttonWidth, buttonHeight, progressBackgroundPaint);
+ canvas.drawRect(0, buttonHeight - progressHeight, progressWidth, buttonHeight, progressPaint);
+
+ // 4. Restore canvas
+ canvas.restore();
+ }
+ }
+
+ /**
+ * Starts an infinite cyclic animation (fills and empties the bar).
+ * Disables the button to prevent spam clicks.
+ */
+ public void startProgress() {
+ if (isRunning) return;
+ isRunning = true;
+ setEnabled(false); // Lock the button immediately
+
+ // Create an animator that goes from 0.0 to 1.0 (empty to full)
+ animator = ValueAnimator.ofFloat(0f, 1f);
+ animator.setDuration(1200); // 1.2 seconds per sweep
+ animator.setRepeatMode(ValueAnimator.REVERSE); // Fill up, then empty down
+ animator.setRepeatCount(ValueAnimator.INFINITE); // Never stop until commanded
+
+ animator.addUpdateListener(animation -> {
+ currentProgress = (float) animation.getAnimatedValue();
+ invalidate(); // Force redraw on every frame
+ });
+
+ animator.start();
+ }
+
+ /**
+ * Stops the animation, clears the bar, and unlocks the button.
+ * To be called by the Controller when the backend confirms the state change.
+ */
+ public void stopProgress() {
+ if (animator != null && animator.isRunning()) {
+ animator.cancel();
+ }
+ isRunning = false;
+ setEnabled(true); // Unlock button
+ currentProgress = 0f; // Reset width
+ invalidate(); // Clear the bar visually
+ }
+}
\ No newline at end of file
diff --git a/apk/controller/app/src/main/res/layout/main.xml b/apk/controller/app/src/main/res/layout/main.xml
index cc23211..7ae5401 100644
--- a/apk/controller/app/src/main/res/layout/main.xml
+++ b/apk/controller/app/src/main/res/layout/main.xml
@@ -343,7 +343,7 @@
android:baselineAligned="false"
android:weightSum="2">
-
+ android:textAllCaps="false"
+
+ app:progressButtonHeight="6dp"
+ app:progressButtonDuration="@integer/server_cool_off_duration_ms"
+ app:progressButtonColor="#FF9800" />
+