diff --git a/apk/controller/app/build.gradle b/apk/controller/app/build.gradle index ad5941b..63cdfe4 100644 --- a/apk/controller/app/build.gradle +++ b/apk/controller/app/build.gradle @@ -79,4 +79,6 @@ dependencies { implementation 'androidx.biometric:biometric:1.1.0' implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.webkit:webkit:1.12.0' + // ZXing for QR Code generation + implementation 'com.google.zxing:core:3.5.2' } diff --git a/apk/controller/app/src/main/AndroidManifest.xml b/apk/controller/app/src/main/AndroidManifest.xml index aa5905a..3940303 100644 --- a/apk/controller/app/src/main/AndroidManifest.xml +++ b/apk/controller/app/src/main/AndroidManifest.xml @@ -42,13 +42,17 @@ - + + + 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 530968e..51d2c93 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 @@ -122,6 +122,8 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe private String serverTransitionText = ""; private final Handler timeoutHandler = new Handler(android.os.Looper.getMainLooper()); private Runnable timeoutRunnable; + private boolean isWifiActive = false; + private boolean isHotspotActive = false; private String currentTargetUrl = null; private long pulseStartTime = 0; @@ -267,6 +269,7 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe deckContainer = findViewById(R.id.deck_container); btnServerControl = findViewById(R.id.btn_server_control); + ImageButton btnShareQr = findViewById(R.id.btn_share_qr); dashboardManager = new DashboardManager(this, findViewById(android.R.id.content), () -> { handleControlClick(); @@ -335,6 +338,23 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe } }); + // --- QR Share Button Logic --- + btnShareQr.setOnClickListener(v -> { + if (!isServerAlive) { + // Rule 1: Server must be running + Snackbar.make(findViewById(android.R.id.content), R.string.qr_error_no_server, Snackbar.LENGTH_LONG).show(); + return; + } + if (!isWifiActive && !isHotspotActive) { + // Rule 2: At least one network must be active + Snackbar.make(findViewById(android.R.id.content), R.string.qr_error_no_network, Snackbar.LENGTH_LONG).show(); + return; + } + + // Launch the new QrActivity + startActivity(new Intent(MainActivity.this, QrActivity.class)); + }); + connectionLog.setMovementMethod(new ScrollingMovementMethod()); connectionLog.setTextIsSelectable(true); connectionLog.setOnTouchListener((v, event) -> { @@ -1003,8 +1023,13 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe 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"); + intent.putExtra("com.termux.RUN_COMMAND_PATH", "/data/data/com.termux/files/usr/bin/env"); + intent.putExtra("com.termux.RUN_COMMAND_ARGUMENTS", new String[]{ + "INTENT_MODE=headless", + "/data/data/com.termux/files/usr/bin/bash", + "/data/data/com.termux/files/usr/bin/iiab-termux", + actionFlag + }); try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -1043,6 +1068,10 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe } catch (Exception ex) {} } + // Store states for the QR button logic + this.isWifiActive = isWifiOn; + this.isHotspotActive = isHotspotOn; + // Let the Dashboard handle the LEDs! if (dashboardManager != null) dashboardManager.updateConnectivityLeds(isWifiOn, isHotspotOn); } diff --git a/apk/controller/app/src/main/java/org/iiab/controller/QrActivity.java b/apk/controller/app/src/main/java/org/iiab/controller/QrActivity.java new file mode 100644 index 0000000..2e9c479 --- /dev/null +++ b/apk/controller/app/src/main/java/org/iiab/controller/QrActivity.java @@ -0,0 +1,184 @@ +package org.iiab.controller; + +import android.graphics.Bitmap; +import android.graphics.Color; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.ImageButton; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import com.google.zxing.BarcodeFormat; +import com.google.zxing.WriterException; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; + +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.util.Collections; +import java.util.List; + +public class QrActivity extends AppCompatActivity { + + private TextView titleText; + private TextView ipText; + private ImageView qrImageView; + private ImageButton btnFlip; + private View cardContainer; + + private String wifiIp = null; + private String hotspotIp = null; + private boolean showingWifi = true; // Tracks which network is currently displayed + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_qr); // Rename your dialog_qr.xml to this + + titleText = findViewById(R.id.qr_network_title); + ipText = findViewById(R.id.qr_ip_text); + qrImageView = findViewById(R.id.qr_image_view); + btnFlip = findViewById(R.id.btn_flip_qr); + cardContainer = findViewById(R.id.qr_card_container); + Button btnClose = findViewById(R.id.btn_close_qr); + + // Improve 3D perspective to avoid visual clipping during rotation + float distance = 8000 * getResources().getDisplayMetrics().density; + cardContainer.setCameraDistance(distance); + + btnClose.setOnClickListener(v -> finish()); + + btnFlip.setOnClickListener(v -> { + // Disable button during animation to prevent spam + btnFlip.setEnabled(false); + animateCardFlip(); + }); + + // 1. Fetch real physical IPs with strict interface naming + fetchNetworkInterfaces(); + + // 2. Determine initial state and button visibility + if (wifiIp != null && hotspotIp != null) { + btnFlip.setVisibility(View.VISIBLE); // Both active, enable flipping + showingWifi = true; + } else if (wifiIp != null) { + btnFlip.setVisibility(View.GONE); + showingWifi = true; + } else if (hotspotIp != null) { + btnFlip.setVisibility(View.GONE); + showingWifi = false; + } else { + // Fallback just in case they died between the MainActivity click and this onCreate + finish(); + return; + } + + updateQrDisplay(); + } + + /** + * Performs a 3D flip animation. Swaps the data halfway through when the card is invisible. + */ + private void animateCardFlip() { + // Phase 1: Rotate out (0 to 90 degrees) + ObjectAnimator flipOut = ObjectAnimator.ofFloat(cardContainer, "rotationY", 0f, 90f); + flipOut.setDuration(200); // 200ms + flipOut.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + // Card is edge-on (invisible). Swap the data! + showingWifi = !showingWifi; + updateQrDisplay(); + + // Phase 2: Rotate in from the other side (-90 to 0 degrees) + cardContainer.setRotationY(-90f); + ObjectAnimator flipIn = ObjectAnimator.ofFloat(cardContainer, "rotationY", -90f, 0f); + flipIn.setDuration(200); + flipIn.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + btnFlip.setEnabled(true); // Unlock button + } + }); + flipIn.start(); + } + }); + flipOut.start(); + } + + /** + * Updates the UI text and generates the new QR Code + */ + private void updateQrDisplay() { + String currentIp = showingWifi ? wifiIp : hotspotIp; + String title = showingWifi ? getString(R.string.qr_title_wifi) : getString(R.string.qr_title_hotspot); + + // 8085 is the default port for the IIAB interface + String url = "http://" + currentIp + ":8085"; + + titleText.setText(title); + ipText.setText(url); + + Bitmap qrBitmap = generateQrCode(url); + if (qrBitmap != null) { + qrImageView.setImageBitmap(qrBitmap); + } + } + + /** + * Strictly categorizes network interfaces to avoid Hotspot being labeled as Wi-Fi. + */ + private void fetchNetworkInterfaces() { + try { + List interfaces = Collections.list(NetworkInterface.getNetworkInterfaces()); + for (NetworkInterface intf : interfaces) { + String name = intf.getName(); + if (!intf.isUp()) continue; + + // Strict categorizations + boolean isStrictWifi = name.equals("wlan0"); + boolean isHotspot = name.startsWith("ap") || name.startsWith("swlan") || name.equals("wlan1") || name.equals("wlan2"); + + if (isStrictWifi || isHotspot) { + List addrs = Collections.list(intf.getInetAddresses()); + for (InetAddress addr : addrs) { + if (!addr.isLoopbackAddress() && addr instanceof Inet4Address) { + if (isStrictWifi) wifiIp = addr.getHostAddress(); + if (isHotspot) hotspotIp = addr.getHostAddress(); + } + } + } + } + } catch (Exception ignored) { } + } + + /** + * Generates a pure Black & White Bitmap using ZXing. + */ + private Bitmap generateQrCode(String text) { + QRCodeWriter writer = new QRCodeWriter(); + try { + // 800x800 guarantees high resolution on any screen + BitMatrix bitMatrix = writer.encode(text, BarcodeFormat.QR_CODE, 800, 800); + int width = bitMatrix.getWidth(); + int height = bitMatrix.getHeight(); + Bitmap bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565); + + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y++) { + bmp.setPixel(x, y, bitMatrix.get(x, y) ? Color.BLACK : Color.WHITE); + } + } + return bmp; + } catch (WriterException e) { + return null; + } + } +} diff --git a/apk/controller/app/src/main/res/layout/activity_qr.xml b/apk/controller/app/src/main/res/layout/activity_qr.xml new file mode 100644 index 0000000..94c94ec --- /dev/null +++ b/apk/controller/app/src/main/res/layout/activity_qr.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + +