Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

buildscript {

ext.PLUGIN_VERSION = "0.2"
ext.PLUGIN_VERSION = "1.0"
ext.ATAK_VERSION = "5.5.0"

def takdevVersion = '3.+'
Expand Down Expand Up @@ -311,6 +311,9 @@ dependencies {
implementation 'androidx.core:core:1.15.0'
implementation 'androidx.lifecycle:lifecycle-service:2.8.7'

// EncryptedSharedPreferences for the lockdown passphrase cache
implementation 'androidx.security:security-crypto:1.1.0-alpha06'

// EXI library for XML processing
implementation 'com.siemens.ct.exi:exificient:1.0.7'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@
import com.atakmap.android.dropdown.DropDownReceiver;
import com.atakmap.android.maps.MapView;
import com.atakmap.android.pluginmeshtastic.meshtastic.AtakMeshtasticBridge;
import com.atakmap.android.pluginmeshtastic.meshtastic.LockdownState;
import com.atakmap.android.pluginmeshtastic.meshtastic.MeshtasticBleScanner;
import com.atakmap.android.pluginmeshtastic.meshtastic.MeshtasticManager;
import android.text.InputType;
import android.widget.LinearLayout;
import com.atakmap.android.pluginmeshtastic.plugin.R;
import com.atakmap.coremap.log.Log;

Expand Down Expand Up @@ -118,6 +121,14 @@ public class MeshtasticDropDownReceiver extends DropDownReceiver implements Drop
private long lastRssiRequest = 0;
private static final long RSSI_REQUEST_INTERVAL_MS = 5000; // Request RSSI every 5 seconds

// Lockdown UI state
private AlertDialog passphraseDialog;
private AlertDialog backoffDialog;
private Runnable backoffTickRunnable;
private TextView lockdownStatusText;
private Button lockNowButton;
private LockdownState currentLockdownState = LockdownState.None.INSTANCE;

public MeshtasticDropDownReceiver(MapView mapView, Context context, AtakMeshtasticBridge bridge) {
super(mapView);
this.pluginContext = context;
Expand All @@ -139,6 +150,184 @@ public MeshtasticDropDownReceiver(MapView mapView, Context context, AtakMeshtast

// Start observing scan results immediately to support autoconnect
observeScanResults();

// Wire the lockdown UI (passphrase dialog, Lock Now button, status text).
initializeLockdownUi();
}

private void initializeLockdownUi() {
lockdownStatusText = templateView.findViewById(R.id.lockdown_status_text);
lockNowButton = templateView.findViewById(R.id.btn_lock_now);
if (lockNowButton != null) {
lockNowButton.setOnClickListener(v -> confirmAndLockNow());
}
meshtasticBridge.setLockdownListener(state -> uiHandler.post(() -> onLockdownState(state)));
}

private void onLockdownState(LockdownState state) {
currentLockdownState = state;
if (lockdownStatusText != null) {
lockdownStatusText.setText(formatLockdownStatus(state));
}
if (state instanceof LockdownState.NeedsProvision) {
dismissBackoffDialog();
showPassphraseDialog(true);
} else if (state instanceof LockdownState.Locked) {
// Locked with any reason — auto-replay happens in the coordinator before we get
// here, so reaching this state means we need to prompt.
dismissBackoffDialog();
showPassphraseDialog(false);
} else if (state instanceof LockdownState.UnlockFailed) {
dismissBackoffDialog();
Toast.makeText(getMapView().getContext(), "Wrong passphrase", Toast.LENGTH_SHORT).show();
showPassphraseDialog(false);
} else if (state instanceof LockdownState.UnlockBackoff) {
dismissPassphraseDialog();
int seconds = ((LockdownState.UnlockBackoff) state).getBackoffSeconds();
showBackoffDialog(seconds);
} else if (state instanceof LockdownState.Unlocked) {
dismissPassphraseDialog();
dismissBackoffDialog();
Toast.makeText(getMapView().getContext(), "Device unlocked", Toast.LENGTH_SHORT).show();
} else if (state instanceof LockdownState.LockNowAcknowledged) {
dismissPassphraseDialog();
dismissBackoffDialog();
Toast.makeText(getMapView().getContext(), "Device locked — disconnecting", Toast.LENGTH_SHORT).show();
meshtasticBridge.disconnect();
}
}

private String formatLockdownStatus(LockdownState state) {
if (state instanceof LockdownState.None) return "Status: not locked";
if (state instanceof LockdownState.NeedsProvision) return "Status: needs passphrase setup";
if (state instanceof LockdownState.Locked) {
String reason = ((LockdownState.Locked) state).getReason();
return reason.isEmpty() ? "Status: locked" : ("Status: locked (" + reason + ")");
}
if (state instanceof LockdownState.UnlockFailed) return "Status: wrong passphrase";
if (state instanceof LockdownState.UnlockBackoff) {
return "Status: rate-limited (" + ((LockdownState.UnlockBackoff) state).getBackoffSeconds() + "s)";
}
if (state instanceof LockdownState.Unlocked) {
LockdownState.Unlocked u = (LockdownState.Unlocked) state;
String exp = u.getValidUntilEpoch() > 0 ? (", until=" + u.getValidUntilEpoch()) : "";
return "Status: unlocked (boots=" + u.getBootsRemaining() + exp + ")";
}
if (state instanceof LockdownState.LockNowAcknowledged) return "Status: locking…";
return "Status: unknown";
}

private void showPassphraseDialog(boolean firstTime) {
if (passphraseDialog != null && passphraseDialog.isShowing()) return;
Context dlgCtx = getMapView().getContext();
LinearLayout container = new LinearLayout(dlgCtx);
container.setOrientation(LinearLayout.VERTICAL);
int pad = (int) (16 * dlgCtx.getResources().getDisplayMetrics().density);
container.setPadding(pad, pad, pad, pad);

if (firstTime) {
TextView hint = new TextView(dlgCtx);
hint.setText("First-time setup — pick a passphrase you can re-enter.");
container.addView(hint);
}

final EditText passField = new EditText(dlgCtx);
passField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
passField.setHint("Passphrase (1–32 chars)");
container.addView(passField);

TextView bootsLabel = new TextView(dlgCtx);
bootsLabel.setText("Boots remaining (0 = firmware default)");
container.addView(bootsLabel);
final EditText bootsField = new EditText(dlgCtx);
bootsField.setInputType(InputType.TYPE_CLASS_NUMBER);
bootsField.setText("0");
container.addView(bootsField);

TextView hoursLabel = new TextView(dlgCtx);
hoursLabel.setText("Hours valid (0 = no time limit)");
container.addView(hoursLabel);
final EditText hoursField = new EditText(dlgCtx);
hoursField.setInputType(InputType.TYPE_CLASS_NUMBER);
hoursField.setText("0");
container.addView(hoursField);

AlertDialog.Builder b = new AlertDialog.Builder(dlgCtx);
b.setTitle(firstTime ? "Set device passphrase" : "Unlock device");
b.setView(container);
b.setPositiveButton(firstTime ? "Set" : "Unlock", (d, w) -> {
String pass = passField.getText().toString();
if (pass.isEmpty() || pass.length() > 32) {
Toast.makeText(dlgCtx, "Passphrase must be 1–32 bytes", Toast.LENGTH_SHORT).show();
return;
}
int boots = parseIntOrZero(bootsField.getText().toString());
int hours = parseIntOrZero(hoursField.getText().toString());
meshtasticBridge.submitLockdownPassphrase(pass, boots, hours);
});
b.setNegativeButton("Cancel", null);
b.setCancelable(false);
passphraseDialog = b.create();
passphraseDialog.show();
}

private void showBackoffDialog(int initialSeconds) {
dismissBackoffDialog();
Context dlgCtx = getMapView().getContext();
AlertDialog.Builder b = new AlertDialog.Builder(dlgCtx);
b.setTitle("Rate-limited");
b.setCancelable(false);
b.setNegativeButton("Cancel", null);
backoffDialog = b.create();
backoffDialog.setMessage("Too many attempts — retry in " + initialSeconds + "s");
backoffDialog.show();

final int[] remaining = { initialSeconds };
backoffTickRunnable = new Runnable() {
@Override public void run() {
remaining[0] -= 1;
if (remaining[0] <= 0) {
dismissBackoffDialog();
// Re-prompt for passphrase once the backoff has elapsed; the firmware will
// tell us again via LOCKDOWN_LOCKED if it's still locked.
if (currentLockdownState instanceof LockdownState.UnlockBackoff) {
showPassphraseDialog(false);
}
return;
}
if (backoffDialog != null && backoffDialog.isShowing()) {
backoffDialog.setMessage("Too many attempts — retry in " + remaining[0] + "s");
}
uiHandler.postDelayed(this, 1000);
}
};
uiHandler.postDelayed(backoffTickRunnable, 1000);
}

private void dismissPassphraseDialog() {
if (passphraseDialog != null && passphraseDialog.isShowing()) passphraseDialog.dismiss();
passphraseDialog = null;
}

private void dismissBackoffDialog() {
if (backoffTickRunnable != null) uiHandler.removeCallbacks(backoffTickRunnable);
backoffTickRunnable = null;
if (backoffDialog != null && backoffDialog.isShowing()) backoffDialog.dismiss();
backoffDialog = null;
}

private void confirmAndLockNow() {
Context dlgCtx = getMapView().getContext();
new AlertDialog.Builder(dlgCtx)
.setTitle("Lock device now?")
.setMessage("This revokes the current session and reboots the device locked. You will need the passphrase to reconnect.")
.setPositiveButton("Lock", (d, w) -> meshtasticBridge.lockNow())
.setNegativeButton("Cancel", null)
.show();
}

private static int parseIntOrZero(String s) {
try { return Integer.parseInt(s.trim()); } catch (Exception e) { return 0; }
}

private void initializeUI() {
Expand Down Expand Up @@ -1088,6 +1277,11 @@ protected void disposeImpl() {
if (bleScanner != null) {
bleScanner.cleanup();
}
if (meshtasticBridge != null) {
meshtasticBridge.setLockdownListener(null);
}
dismissPassphraseDialog();
dismissBackoffDialog();
}

@Override
Expand Down Expand Up @@ -1148,6 +1342,18 @@ public void onAutoReconnectionComplete() {
});
}

/**
* Called by the bridge whenever a Config response is parsed and cached. The
* Device-Information card reads from cached config; this push lets it re-render
* after a post-reboot reconnect without the user toggling Disconnect/Connect.
*/
public void onConfigUpdated() {
uiHandler.post(() -> {
updateConnectionStatus();
updateDeviceInfoForced();
});
}

/**
* Called when device name is updated (e.g., when NodeInfo is received)
* This refreshes the UI to show the updated device name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,12 +167,20 @@ class AtakMeshtasticBridge(
// Create and initialize the manager
meshtasticManager = MeshtasticManager(mapViewContext)
meshtasticManager?.registerAtakMessageHandler(this)

// Attach any lockdown listener that was registered before the manager existed.
attachLockdownListener()

// Set up callback to update device name when NodeInfo is received
meshtasticManager?.setDeviceNameUpdateCallback { longName ->
Log.i(TAG, "NodeInfo callback triggered with longName: '$longName'")
updateStoredDeviceName(longName)
}

// Poke the UI whenever a Config response is cached (region, role, etc.).
meshtasticManager?.setConfigUpdateCallback {
notifyConfigUpdated()
}

// Auto-connect if configured
if (autoConnectBluetooth && bluetoothAddress != null) {
Expand Down Expand Up @@ -229,6 +237,20 @@ class AtakMeshtasticBridge(
}
}

/** Notify the DropDownReceiver that fresh Config data was cached. */
private fun notifyConfigUpdated() {
dropDownReceiver?.let { receiver ->
try {
val method = receiver::class.java.getMethod("onConfigUpdated")
method.invoke(receiver)
} catch (_: NoSuchMethodException) {
// Receiver doesn't care — silently skip.
} catch (e: Exception) {
Log.w(TAG, "Failed to notify DropDownReceiver of config update", e)
}
}
}

/**
* Notify the DropDownReceiver that the device name has been updated
*/
Expand Down Expand Up @@ -426,6 +448,46 @@ class AtakMeshtasticBridge(
autoConnectUsb = false
meshtasticManager?.disconnect()
}

/** Lockdown coordinator surface for the UI (state flow + actions). */
fun getLockdownCoordinator(): LockdownCoordinator? = meshtasticManager?.lockdownCoordinator

fun submitLockdownPassphrase(passphrase: String, boots: Int, hours: Int) {
meshtasticManager?.lockdownCoordinator?.submitPassphrase(passphrase, boots, hours)
}

fun lockNow() {
meshtasticManager?.lockdownCoordinator?.lockNow()
}

/** Java-friendly listener for lockdown state changes (delivered on the main thread). */
interface LockdownListener {
fun onLockdownState(state: LockdownState)
}

private var lockdownListenerJob: Job? = null
private var pendingLockdownListener: LockdownListener? = null

fun setLockdownListener(listener: LockdownListener?) {
pendingLockdownListener = listener
attachLockdownListener()
}

private fun attachLockdownListener() {
lockdownListenerJob?.cancel()
lockdownListenerJob = null
val listener = pendingLockdownListener ?: return
val coord = meshtasticManager?.lockdownCoordinator ?: return // will retry after init
lockdownListenerJob = scope.launch {
coord.state.collect { state ->
try {
listener.onLockdownState(state)
} catch (e: Exception) {
Log.w(TAG, "Lockdown listener threw", e)
}
}
}
}

/**
* Set the channel password (PSK) on the connected device
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ interface RadioCallback {

class BluetoothInterface(
private val context: Context,
private val deviceAddress: String,
val deviceAddress: String,
private val callback: RadioCallback
) {
companion object {
Expand Down
Loading