Most enterprise MDM solutions for Android require enrolling devices in Google Workspace/Enterprise (EMM). This works well for corporate-owned devices but creates friction for BYOD scenarios — employees don't want to give their employer Google-level control over their personal phone.
XCloak takes a different approach: a Flutter foreground service that collects security telemetry and accepts management commands, without requiring EMM enrollment.
What We Can Do Without Device Owner
Android restricts many sensitive management APIs to "Device Owner" (DO) or "Profile Owner" (PO) mode. Without them:
| Feature | Requires DO/PO | XCloak approach |
|---|---|---|
| Screen lock detection | ✅ (DevicePolicyManager) | Not available — has_passcode is null |
| Remote lock | ✅ (DevicePolicyManager.lockNow) | Requires DO; BYOD: returns explanation |
| Remote wipe | ✅ | Requires DO; BYOD: intentionally rejected |
| VPN enforcement | ✅ | Not enforced; VPN detected via interface list |
| App whitelist/blacklist | ✅ | Not enforced; sideloads detected via inventory |
The key insight: for BYOD, detection is more useful than enforcement. We can detect root, USB debugging, unknown sources, VPN absence, battery risk, and storage risk — and report these as a posture score to the SOC. The operator sees the risk; the user isn't locked out of their phone.
How We Detect Root
Root detection is a multi-layer check with no single point of bypass:
static Future<bool> _checkRooted(AndroidDeviceInfo info) async {
// Layer 1: Build tags — test-keys means the ROM is not production-signed
if (info.tags.contains('test-keys')) return true;
// Layer 2: Well-known su binary locations
const suPaths = [
'/system/app/Superuser.apk', '/sbin/su', '/system/bin/su',
'/system/xbin/su', '/data/local/xbin/su', '/data/local/bin/su',
'/system/xbin/busybox', '/data/local/tmp/su',
];
for (final path in suPaths) {
if (await File(path).exists()) return true;
}
// Layer 3: Magisk socket (modern rootkit hiding method)
try {
final result = await Process.run('test', ['-S', '/dev/.magisk/mirror']);
if (result.exitCode == 0) return true;
} catch (_) {}
return false;
}
Determined adversaries can bypass this — no BYOD root detection is perfect. The goal is detecting common rooted devices and unintentionally rooted phones (factory-rooted development devices, user-rooted phones with Magisk), not adversaries actively trying to hide.
VPN Detection via NetworkInterface
Android doesn't provide a direct "is VPN active?" API without DO mode. Instead, we inspect the network interface list:
final interfaces = await NetworkInterface.list();
vpn = interfaces.any((i) =>
i.name.startsWith('tun') ||
i.name.startsWith('ppp') ||
i.name.startsWith('vpn'));
tun interfaces are created by WireGuard, OpenVPN, and most VPN clients. This covers the vast majority of VPN scenarios on Android without any special permissions.
Battery and Storage via Process.run
The Flutter SDK doesn't expose battery level or storage stats in a way that works from a background service isolate. We use Process.run to shell out to Android's command-line tools:
// Battery level and charging state
final result = await Process.run('dumpsys', ['battery']);
final out = result.stdout as String;
final levelMatch = RegExp(r'level: (\d+)').firstMatch(out);
// status 2 = CHARGING, 5 = FULL
final statusMatch = RegExp(r'status: (\d+)').firstMatch(out);
// Storage
final dfResult = await Process.run('df', ['-h', '/data']);
// Parse: Filesystem Size Used Avail Use% Mounted
These shell commands are available to non-root apps on Android — they don't require elevated permissions.
The Foreground Service Architecture
Android aggressively kills background processes. To keep the agent running while the app is closed, we use a foreground service — a service that shows a persistent notification to the user.
@pragma('vm:entry-point')
void onServiceStart(ServiceInstance service) async {
if (service is AndroidServiceInstance) {
service.setAsForegroundService();
}
// Immediate first run to confirm liveness
await _checkIn(service);
await _pollCommands();
// 5 staggered periodic timers with jitter
_schedulePeriodic(_checkinInterval, () => _checkIn(service));
_schedulePeriodic(_cmdPollInterval, _pollCommands);
_schedulePeriodic(_logInterval, LogForwarder.forwardBatch);
_schedulePeriodic(_inventoryInterval, _runInventory);
_schedulePeriodic(_threatInterval, _runThreatScan);
}
Each timer uses _schedulePeriodic() which adds random jitter (0–30 s) before the first tick. On a large fleet, this prevents all devices rebooting after an OS update from hitting the backend simultaneously.
The foreground notification degrades from "Monitoring device security…" to "Agent degraded — server unreachable" after 5 consecutive check-in failures, giving users a visible signal that something is wrong.