Add floating log output menu and fix several issues

This commit is contained in:
Mahdi Karzari
2026-07-27 14:04:49 +03:30
parent e4b4f6e350
commit 44cf537d89
12 changed files with 568 additions and 14 deletions
@@ -420,7 +420,7 @@ public class BActivityThread extends IBActivityThread.Stub {
Slog.w(TAG, "Unable to set guest context ClassLoader: " + error.getMessage());
}
}
FridaGadgetLoader.loadIfEnabled();
FridaGadgetLoader.loadAtProcessBind();
AppBindData bindData = new AppBindData();
bindData.appInfo = applicationInfo;
@@ -15,19 +15,36 @@ public final class FridaGadgetLoader {
private FridaGadgetLoader() {
}
/** Listener mode starts immediately; autonomous scripts wait for the guest lifecycle. */
public static boolean loadAtProcessBind() {
String packageName = GuestRuntimeRegistry.getGuestPackageName();
String mode = InstrumentationSettings.getModeForPackage(packageName);
if (InstrumentationSettings.MODE_LOCAL_SCRIPT.equals(mode)) {
Log.i(TAG, "Deferring on-device agent until the guest application is ready");
return false;
}
return loadIfEnabled();
}
public static boolean loadIfEnabled() {
if (!GuestRuntimeRegistry.isInstrumentationEnabled()) {
Log.i(TAG, "Instrumentation disabled for this guest process");
return false;
}
String packageName = GuestRuntimeRegistry.getGuestPackageName();
String mode = InstrumentationSettings.getModeForPackage(packageName);
if (InstrumentationSettings.MODE_LOCAL_SCRIPT.equals(mode)
&& !GuestRuntimeRegistry.isPrimaryProcess()) {
Log.i(TAG, "Skipping on-device agent in secondary process "
+ GuestRuntimeRegistry.getGuestProcessName());
return false;
}
if (loaded) return true;
synchronized (LOAD_LOCK) {
if (loaded) return true;
if (!ATTEMPTED.compareAndSet(false, true)) return false;
try {
InstrumentationStatusStore.recordBinding();
String packageName = GuestRuntimeRegistry.getGuestPackageName();
String mode = InstrumentationSettings.getModeForPackage(packageName);
if (InstrumentationSettings.MODE_LOCAL_SCRIPT.equals(mode)) {
String scriptPath = InstrumentationSettings.getScriptPathForPackage(packageName);
File runtime = LocalScriptGadgetRuntime.prepare(packageName, scriptPath);
@@ -56,6 +56,11 @@ public final class GuestRuntimeRegistry {
public static ClassLoader getGuestClassLoader() { return guestClassLoader; }
public static String getGuestSourceDir() { return guestSourceDir; }
public static boolean isInstrumentationEnabled() { return instrumentationEnabled; }
public static boolean isPrimaryProcess() {
String primaryProcess = guestApplicationInfo == null ? null : guestApplicationInfo.processName;
if (primaryProcess == null || primaryProcess.isEmpty()) primaryProcess = guestPackageName;
return primaryProcess != null && primaryProcess.equals(guestProcessName);
}
public static String getLastError() { return lastError; }
public static long getInitializationTimestamp() { return initializationTimestamp; }
@@ -81,6 +86,7 @@ public final class GuestRuntimeRegistry {
"\"virtualProcessId\":" + virtualProcessId + ',' +
"\"sourceDir\":" + quote(guestSourceDir) + ',' +
"\"instrumentationEnabled\":" + instrumentationEnabled + ',' +
"\"primaryProcess\":" + isPrimaryProcess() + ',' +
"\"lastError\":" + quote(lastError) + ',' +
"\"initializedAt\":" + initializationTimestamp +
'}';
@@ -16,6 +16,7 @@ public final class InstrumentationStatusStore {
}
public static void recordBinding() {
if (!GuestRuntimeRegistry.isPrimaryProcess()) return;
String packageName = GuestRuntimeRegistry.getGuestPackageName();
String mode = InstrumentationSettings.getModeForPackage(packageName);
String state;
@@ -49,6 +50,7 @@ public final class InstrumentationStatusStore {
}
public static void recordLoaded() {
if (!GuestRuntimeRegistry.isPrimaryProcess()) return;
String mode = preferences().getString("runtime_mode", InstrumentationSettings.MODE_COMPUTER);
String state = InstrumentationSettings.MODE_LOCAL_SCRIPT.equals(mode)
? "local_script_active" : "computer_attached";
@@ -56,6 +58,7 @@ public final class InstrumentationStatusStore {
}
public static void recordError(String error) {
if (!GuestRuntimeRegistry.isPrimaryProcess()) return;
preferences().edit().putString("runtime_state", "failed").putString("runtime_error", error).commit();
}
}
@@ -3,6 +3,7 @@ package top.niunaijun.blackbox.instrumentation;
import android.content.Context;
import java.io.File;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
@@ -14,6 +15,8 @@ import top.niunaijun.blackbox.BlackBoxCore;
final class LocalScriptGadgetRuntime {
private static final String AGENT_ROOT = "fridabox-agents";
private static final String AGENT_NAME = "agent.js";
private static final String INSTRUMENTED_AGENT_NAME = "fridabox-runtime.js";
private static final String LOG_NAME = "runtime.jsonl";
private static final String RUNTIME_NAME = "libfridabox-agent.so";
private static final String CONFIG_NAME = "libfridabox-agent.config.so";
@@ -35,6 +38,14 @@ final class LocalScriptGadgetRuntime {
}
File directory = script.getParentFile();
File log = new File(directory, LOG_NAME);
writeUtf8Atomically(log, "");
File instrumentedAgent = new File(directory, INSTRUMENTED_AGENT_NAME);
writeInstrumentedAgentAtomically(instrumentedAgent, script, buildLogBridge(log));
if (!instrumentedAgent.setReadable(true, true) || !instrumentedAgent.setWritable(false, false)) {
throw new IOException("Unable to secure the instrumented JavaScript agent");
}
File source = new File(context.getApplicationInfo().nativeLibraryDir, "libfrida-gadget.so");
if (!source.isFile()) throw new IOException("Packaged Frida Gadget is missing");
@@ -49,23 +60,66 @@ final class LocalScriptGadgetRuntime {
}
File config = new File(directory, CONFIG_NAME);
writeUtf8Atomically(config, buildConfig(packageName));
writeUtf8Atomically(config, buildConfig(packageName, instrumentedAgent.getAbsolutePath()));
return runtime;
}
static String buildConfig(String packageName) {
static String buildConfig(String packageName, String agentPath) {
return "{\n" +
" \"interaction\": {\n" +
" \"type\": \"script\",\n" +
" \"path\": \"" + AGENT_NAME + "\",\n" +
" \"on_change\": \"reload\",\n" +
" \"path\": \"" + json(agentPath) + "\",\n" +
" \"on_change\": \"ignore\",\n" +
" \"parameters\": { \"package\": \"" + json(packageName) + "\" }\n" +
" },\n" +
" \"runtime\": \"qjs\",\n" +
" \"runtime\": \"v8\",\n" +
" \"teardown\": \"minimal\"\n" +
"}\n";
}
static String buildLogBridge(File log) {
return "(function () {\n" +
" try {\n" +
" const logPath = \"" + json(log.getAbsolutePath()) + "\";\n" +
" const maxBytes = 512 * 1024;\n" +
" const original = { log: console.log, warn: console.warn, error: console.error, send: globalThis.send };\n" +
" function render(value) {\n" +
" if (typeof value === 'string') return value;\n" +
" try { return JSON.stringify(value); } catch (_) { return String(value); }\n" +
" }\n" +
" function append(level, values) {\n" +
" try {\n" +
" const message = Array.prototype.map.call(values, render).join(' ');\n" +
" let line = JSON.stringify({ time: Date.now(), level: level, message: message }) + '\\n';\n" +
" let existing = '';\n" +
" try { existing = File.readAllText(logPath); } catch (_) {}\n" +
" if (existing.length + line.length > maxBytes) {\n" +
" const notice = JSON.stringify({ time: Date.now(), level: 'system', message: 'Earlier logs were truncated' }) + '\\n';\n" +
" const keep = Math.max(0, maxBytes - notice.length - line.length);\n" +
" existing = existing.slice(Math.max(0, existing.length - keep));\n" +
" line = notice + line;\n" +
" File.writeAllText(logPath, existing + line);\n" +
" return;\n" +
" }\n" +
" const stream = new File(logPath, 'a');\n" +
" try { stream.write(line); stream.flush(); } finally { stream.close(); }\n" +
" } catch (error) { try { original.error.call(console, '[FridaBox log bridge]', error.stack || error); } catch (_) {} }\n" +
" }\n" +
" console.log = function () { append('log', arguments); return original.log.apply(console, arguments); };\n" +
" console.warn = function () { append('warn', arguments); return original.warn.apply(console, arguments); };\n" +
" console.error = function () { append('error', arguments); return original.error.apply(console, arguments); };\n" +
" const wrappedSend = function (payload, data) { append('send', [payload]); return original.send(payload, data); };\n" +
" Object.defineProperty(globalThis, '__fridaboxSend', { value: wrappedSend, configurable: true });\n" +
" try {\n" +
" const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'send');\n" +
" if (!descriptor || descriptor.writable) globalThis.send = wrappedSend;\n" +
" else if (descriptor.configurable) Object.defineProperty(globalThis, 'send', Object.assign({}, descriptor, { value: wrappedSend }));\n" +
" } catch (error) { append('system', ['send() capture unavailable: ' + error]); }\n" +
" append('system', ['On-device agent started']);\n" +
" } catch (error) { try { console.error('[FridaBox log bridge]', error.stack || error); } catch (_) {} }\n" +
"})();\n";
}
static boolean isInside(File root, File child) {
String rootPath = root.getAbsolutePath();
String childPath = child.getAbsolutePath();
@@ -107,6 +161,101 @@ final class LocalScriptGadgetRuntime {
replace(temporary, destination);
}
private static void writeInstrumentedAgentAtomically(
File destination, File source, String bridge) throws IOException {
File temporary = new File(destination.getParentFile(), destination.getName() + ".partial");
if (temporary.exists() && !temporary.delete()) {
throw new IOException("Unable to replace temporary JavaScript agent");
}
try (FileInputStream input = new FileInputStream(source);
ByteArrayOutputStream sourceBytes = new ByteArrayOutputStream((int) source.length())) {
byte[] buffer = new byte[64 * 1024];
int count;
while ((count = input.read(buffer)) >= 0) sourceBytes.write(buffer, 0, count);
byte[] instrumented = instrumentAgent(sourceBytes.toByteArray(), bridge);
try (FileOutputStream output = new FileOutputStream(temporary)) {
output.write(instrumented);
output.getFD().sync();
}
}
replace(temporary, destination);
}
static byte[] instrumentAgent(byte[] source, String bridge) throws IOException {
int offset = injectionOffset(source);
if (offset < 0) throw new IOException("Invalid Frida bundle header");
byte[] prefix = (bridge + "(function (send) {\n").getBytes(StandardCharsets.UTF_8);
byte[] suffix = "\n})(globalThis.__fridaboxSend);\n".getBytes(StandardCharsets.UTF_8);
if (offset > 0) return instrumentBundle(source, prefix, suffix, offset);
ByteArrayOutputStream output = new ByteArrayOutputStream(source.length + prefix.length + suffix.length);
output.write(prefix, 0, prefix.length);
output.write(source, 0, source.length);
output.write(suffix, 0, suffix.length);
return output.toByteArray();
}
private static byte[] instrumentBundle(
byte[] source, byte[] prefix, byte[] suffix, int bodyOffset) throws IOException {
int lengthStart = 0;
while (lengthStart < bodyOffset && source[lengthStart] != '\n') lengthStart++;
lengthStart++;
int lengthEnd = lengthStart;
long declaredLength = 0;
while (lengthEnd < bodyOffset && source[lengthEnd] >= '0' && source[lengthEnd] <= '9') {
declaredLength = declaredLength * 10 + (source[lengthEnd] - '0');
lengthEnd++;
}
if (lengthStart >= bodyOffset || lengthEnd == lengthStart
|| lengthEnd >= bodyOffset || source[lengthEnd] != ' ') {
throw new IOException("Invalid Frida bundle module length");
}
long bodyEndLong = bodyOffset + declaredLength;
if (bodyEndLong > source.length) throw new IOException("Invalid Frida bundle module length");
int bodyEnd = (int) bodyEndLong;
byte[] updatedLength = Long.toString(declaredLength + prefix.length + suffix.length)
.getBytes(StandardCharsets.US_ASCII);
ByteArrayOutputStream output = new ByteArrayOutputStream(
source.length + prefix.length + suffix.length
+ updatedLength.length - (lengthEnd - lengthStart));
output.write(source, 0, lengthStart);
output.write(updatedLength, 0, updatedLength.length);
output.write(source, lengthEnd, bodyOffset - lengthEnd);
output.write(prefix, 0, prefix.length);
output.write(source, bodyOffset, bodyEnd - bodyOffset);
output.write(suffix, 0, suffix.length);
output.write(source, bodyEnd, source.length - bodyEnd);
return output.toByteArray();
}
static int injectionOffset(byte[] source) {
byte[] magic = "📦".getBytes(StandardCharsets.UTF_8);
if (!startsWith(source, magic)) return 0;
byte[] unixMarker = "\n✄\n".getBytes(StandardCharsets.UTF_8);
int unix = indexOf(source, unixMarker);
if (unix >= 0) return unix + unixMarker.length;
byte[] windowsMarker = "\r\n✄\r\n".getBytes(StandardCharsets.UTF_8);
int windows = indexOf(source, windowsMarker);
return windows < 0 ? -1 : windows + windowsMarker.length;
}
private static boolean startsWith(byte[] value, byte[] prefix) {
if (value.length < prefix.length) return false;
for (int i = 0; i < prefix.length; i++) {
if (value[i] != prefix[i]) return false;
}
return true;
}
private static int indexOf(byte[] value, byte[] needle) {
for (int i = 0; i <= value.length - needle.length; i++) {
int j = 0;
while (j < needle.length && value[i + j] == needle[j]) j++;
if (j == needle.length) return i;
}
return -1;
}
private static void replace(File temporary, File destination) throws IOException {
if (destination.exists() && !destination.delete()) throw new IOException("Unable to replace " + destination.getName());
if (!temporary.renameTo(destination)) throw new IOException("Unable to install " + destination.getName());
@@ -20,6 +20,7 @@ public class GuestRuntimeRegistryTest {
public void initializeReplacesProcessLocalSnapshot() {
ApplicationInfo info = new ApplicationInfo();
info.sourceDir = "/private/original.apk";
info.processName = "sample.one";
ClassLoader loader = getClass().getClassLoader();
GuestRuntimeRegistry.initialize("sample.one", "sample.one:remote", 3, 7, info, loader, true);
@@ -30,9 +31,19 @@ public class GuestRuntimeRegistryTest {
assertSame(loader, GuestRuntimeRegistry.getGuestClassLoader());
assertEquals("/private/original.apk", GuestRuntimeRegistry.getGuestSourceDir());
assertTrue(GuestRuntimeRegistry.isInstrumentationEnabled());
assertFalse(GuestRuntimeRegistry.isPrimaryProcess());
assertTrue(GuestRuntimeRegistry.describe().contains("\"package\":\"sample.one\""));
}
@Test
public void primaryProcessUsesApplicationProcessName() {
ApplicationInfo info = new ApplicationInfo();
info.processName = "sample.custom";
GuestRuntimeRegistry.initialize("sample", "sample.custom", 0, 1, info, null, true);
assertTrue(GuestRuntimeRegistry.isPrimaryProcess());
}
@Test
public void clearRemovesPriorGuest() {
GuestRuntimeRegistry.initialize("sample", "sample", 0, 1, null, null, true);
@@ -6,16 +6,54 @@ import static org.junit.Assert.assertTrue;
import org.junit.Test;
import java.io.File;
import java.nio.charset.StandardCharsets;
public class LocalScriptGadgetRuntimeTest {
@Test
public void configUsesAutonomousScriptInteraction() {
String config = LocalScriptGadgetRuntime.buildConfig("sample.\"guest");
String config = LocalScriptGadgetRuntime.buildConfig(
"sample.\"guest", "/private/agents/fridabox-runtime.js");
assertTrue(config.contains("\"type\": \"script\""));
assertTrue(config.contains("\"path\": \"agent.js\""));
assertTrue(config.contains("\"path\": \"/private/agents/fridabox-runtime.js\""));
assertTrue(config.contains("\"on_change\": \"ignore\""));
assertTrue(config.contains("\"runtime\": \"v8\""));
assertTrue(config.contains("sample.\\\"guest"));
}
@Test
public void logBridgeCapturesConsoleAndSendWithoutChangingTheSourceAgent() {
String bridge = LocalScriptGadgetRuntime.buildLogBridge(new File("runtime.jsonl"));
assertTrue(bridge.contains("console.log = function"));
assertTrue(bridge.contains("console.error = function"));
assertTrue(bridge.contains("Object.getOwnPropertyDescriptor(globalThis, 'send')"));
assertTrue(bridge.contains("__fridaboxSend"));
assertTrue(bridge.contains("runtime.jsonl"));
assertTrue(bridge.contains("new File(logPath, 'a')"));
}
@Test
public void compiledBundleKeepsItsHeaderBeforeTheInjectedBridge() throws Exception {
String source = "📦\n16 /scripts/sample-hook.js\n✄\nvar hook = true;";
String bridge = "console.log('bridge');\n";
String result = new String(LocalScriptGadgetRuntime.instrumentAgent(
source.getBytes(StandardCharsets.UTF_8), bridge), StandardCharsets.UTF_8);
assertTrue(result.contains("\n" + bridge + "(function (send) {\nvar hook = true;"));
assertTrue(result.contains("})(globalThis.__fridaboxSend);"));
}
@Test
public void plainScriptReceivesBridgeAtTheBeginning() throws Exception {
String source = "console.log('agent');";
String bridge = "console.log('bridge');\n";
String result = new String(LocalScriptGadgetRuntime.instrumentAgent(
source.getBytes(StandardCharsets.UTF_8), bridge), StandardCharsets.UTF_8);
assertTrue(result.startsWith(bridge));
assertTrue(result.contains("(function (send) {\n" + source));
assertTrue(result.endsWith("})(globalThis.__fridaboxSend);\n"));
}
@Test
public void privatePathCheckRejectsSiblingPrefix() {
File root = new File("/data/user/0/host/files/fridabox-agents");
@@ -377,6 +377,16 @@ class FridaBoxActivity : AppCompatActivity() {
}
private fun launch(packageName: String, mode: String) {
settings.edit()
.putString("runtime_package", packageName)
.putString("runtime_mode", mode)
.putString("runtime_state", when (mode) {
InstrumentationSettings.MODE_LOCAL_SCRIPT -> "loading_local_script"
InstrumentationSettings.MODE_COMPUTER -> "waiting_for_attach"
else -> "idle"
})
.putString("runtime_error", null)
.apply()
setLoading(true)
worker.execute {
val result = runCatching {
@@ -625,7 +635,7 @@ class FridaBoxActivity : AppCompatActivity() {
binding.toolbar.title = getString(R.string.fb_runtime_title)
binding.toolbar.subtitle = getString(R.string.fb_runtime_subtitle)
val packageName = settings.getString("runtime_package", packageHint) ?: packageHint
val packageName = packageHint ?: settings.getString("runtime_package", null)
val state = settings.getString("runtime_state", "idle") ?: "idle"
val mode = settings.getString("runtime_mode", packageName?.let {
InstrumentationSettings.getModeForPackage(it)
@@ -664,6 +674,17 @@ class FridaBoxActivity : AppCompatActivity() {
append("\n\nThe private agent is loaded autonomously by Frida Gadget.")
}
))
if (packageName != null) {
binding.content.addView(outlineButton("Open agent logs") {
if (!FridaBoxGuestLogOverlay.show(this, packageName)) {
toast("No agent log is available for $packageName")
}
}.apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(50)).apply {
topMargin = dp(14)
}
})
}
} else {
binding.content.addView(infoCard("Clean launch", getString(R.string.fb_mode_clean_body)))
}
@@ -0,0 +1,284 @@
package com.qm4rs.fridabox
import android.app.Activity
import android.app.Dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.GradientDrawable
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import org.json.JSONObject
import top.niunaijun.blackbox.instrumentation.InstrumentationSettings
import java.io.File
import java.text.SimpleDateFormat
import java.util.Locale
/** Package-scoped floating log console shown inside a virtual guest Activity. */
object FridaBoxGuestLogOverlay {
private const val OVERLAY_TAG = "fridabox.guest.log.overlay"
private const val LOG_NAME = "runtime.jsonl"
private const val REFRESH_INTERVAL_MS = 750L
private val backgroundColor = Color.rgb(8, 13, 18)
private val surfaceColor = Color.rgb(18, 26, 34)
private val surfaceHighColor = Color.rgb(29, 40, 51)
private val primaryColor = Color.rgb(110, 231, 216)
private val textColor = Color.rgb(231, 238, 244)
private val secondaryTextColor = Color.rgb(148, 164, 178)
private val outlineColor = Color.rgb(49, 65, 80)
fun attach(activity: Activity, packageName: String) {
val decor = activity.window?.decorView as? ViewGroup ?: return
val existing = decor.findViewWithTag<View>(OVERLAY_TAG)
val localMode = InstrumentationSettings.MODE_LOCAL_SCRIPT ==
InstrumentationSettings.getModeForPackage(packageName)
val logFile = logFileFor(packageName)
if (!localMode || logFile == null) {
if (existing != null) decor.removeView(existing)
return
}
if (existing != null) {
existing.bringToFront()
return
}
val bubble = TextView(activity).apply {
tag = OVERLAY_TAG
text = ">_"
contentDescription = "Open Frida logs for $packageName"
gravity = Gravity.CENTER
setTextColor(Color.BLACK)
textSize = 15f
setTypeface(Typeface.MONOSPACE, Typeface.BOLD)
background = rounded(primaryColor, dp(activity, 18))
elevation = dp(activity, 10).toFloat()
setOnClickListener { showConsole(activity, packageName, logFile) }
}
val margin = dp(activity, 18)
decor.addView(bubble, FrameLayout.LayoutParams(dp(activity, 54), dp(activity, 54)).apply {
gravity = Gravity.END or Gravity.BOTTOM
marginEnd = margin
bottomMargin = dp(activity, 30)
})
makeDraggable(bubble, decor)
bubble.bringToFront()
}
fun show(activity: Activity, packageName: String): Boolean {
val logFile = logFileFor(packageName) ?: return false
showConsole(activity, packageName, logFile)
return true
}
private fun makeDraggable(view: View, parent: ViewGroup) {
val touchSlop = dp(view.context, 8).toFloat()
var downX = 0f
var downY = 0f
var startTranslationX = 0f
var startTranslationY = 0f
var dragged = false
view.setOnTouchListener { target, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
downX = event.rawX
downY = event.rawY
startTranslationX = target.translationX
startTranslationY = target.translationY
dragged = false
true
}
MotionEvent.ACTION_MOVE -> {
val dx = event.rawX - downX
val dy = event.rawY - downY
dragged = dragged || kotlin.math.abs(dx) > touchSlop || kotlin.math.abs(dy) > touchSlop
val minX = -target.left.toFloat()
val maxX = (parent.width - target.right).toFloat()
val minY = -target.top.toFloat()
val maxY = (parent.height - target.bottom).toFloat()
target.translationX = (startTranslationX + dx).coerceIn(minX, maxX)
target.translationY = (startTranslationY + dy).coerceIn(minY, maxY)
true
}
MotionEvent.ACTION_UP -> {
if (!dragged) target.performClick()
true
}
MotionEvent.ACTION_CANCEL -> true
else -> false
}
}
}
private fun showConsole(activity: Activity, packageName: String, logFile: File) {
if (activity.isFinishing || activity.isDestroyed) return
val handler = Handler(Looper.getMainLooper())
val root = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(activity, 20), dp(activity, 20), dp(activity, 20), dp(activity, 16))
background = rounded(surfaceColor, dp(activity, 22))
}
root.addView(TextView(activity).apply {
text = "On-device agent logs"
textSize = 20f
setTypeface(typeface, Typeface.BOLD)
setTextColor(textColor)
})
val status = TextView(activity).apply {
text = packageName
textSize = 11.5f
setTextColor(secondaryTextColor)
setPadding(0, dp(activity, 4), 0, 0)
}
root.addView(status)
val output = TextView(activity).apply {
text = "No logs yet."
typeface = Typeface.MONOSPACE
textSize = 12f
setTextColor(textColor)
setTextIsSelectable(true)
setPadding(dp(activity, 14), dp(activity, 14), dp(activity, 14), dp(activity, 14))
background = rounded(backgroundColor, dp(activity, 12))
}
val scroll = ScrollView(activity).apply {
isFillViewport = true
addView(output, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
))
}
root.addView(scroll, LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0,
1f
).apply {
topMargin = dp(activity, 14)
bottomMargin = dp(activity, 14)
})
val actions = LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
}
var hiddenBefore = 0L
var fileSignature = Long.MIN_VALUE
val clear = button(activity, "Clear view", false) {
hiddenBefore = System.currentTimeMillis()
fileSignature = Long.MIN_VALUE
output.text = "No logs yet."
}
val copy = button(activity, "Copy", false) {
val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("$packageName Frida logs", output.text))
Toast.makeText(activity, "Agent logs copied", Toast.LENGTH_SHORT).show()
}
lateinit var dialog: Dialog
val close = button(activity, "Close", true) { dialog.dismiss() }
actions.addView(clear, LinearLayout.LayoutParams(0, dp(activity, 44), 1f))
actions.addView(copy, LinearLayout.LayoutParams(0, dp(activity, 44), 1f).apply {
marginStart = dp(activity, 8)
})
actions.addView(close, LinearLayout.LayoutParams(0, dp(activity, 44), 1f).apply {
marginStart = dp(activity, 8)
})
root.addView(actions)
dialog = Dialog(activity).apply {
setContentView(root)
setCanceledOnTouchOutside(true)
window?.apply {
setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
attributes = attributes.apply { dimAmount = 0.58f }
}
}
var snapshot = ""
lateinit var refresh: Runnable
refresh = Runnable {
if (!dialog.isShowing) return@Runnable
val length = logFile.length()
val nextSignature = length xor logFile.lastModified()
if (nextSignature != fileSignature) {
fileSignature = nextSignature
val next = formatLogs(logFile, hiddenBefore)
if (next != snapshot) {
val stayAtBottom = scroll.scrollY + scroll.height >= output.height - dp(activity, 24)
snapshot = next
output.text = next.ifBlank { "No logs yet." }
if (stayAtBottom) scroll.post { scroll.fullScroll(View.FOCUS_DOWN) }
}
val size = if (length in 1..1023) "<1 KiB" else "${length / 1024} KiB"
status.text = "$packageName · $size"
}
handler.postDelayed(refresh, REFRESH_INTERVAL_MS)
}
dialog.setOnShowListener {
val metrics = activity.resources.displayMetrics
dialog.window?.setLayout((metrics.widthPixels * 0.92f).toInt(), (metrics.heightPixels * 0.76f).toInt())
handler.post(refresh)
}
dialog.setOnDismissListener { handler.removeCallbacks(refresh) }
dialog.show()
}
private fun logFileFor(packageName: String): File? {
val scriptPath = InstrumentationSettings.getScriptPathForPackage(packageName) ?: return null
val directory = File(scriptPath).parentFile ?: return null
return File(directory, LOG_NAME)
}
private fun formatLogs(file: File, hiddenBefore: Long): String {
if (!file.isFile || file.length() == 0L) return ""
val formatter = SimpleDateFormat("HH:mm:ss.SSS", Locale.ROOT)
return runCatching {
file.readLines(Charsets.UTF_8).mapNotNull { line ->
runCatching<String?> {
val item = JSONObject(line)
val time = item.optLong("time")
if (time <= hiddenBefore) return@runCatching null
val timestamp = formatter.format(java.util.Date(time))
val level = item.optString("level", "log").uppercase(Locale.ROOT)
"[$timestamp] ${level.padEnd(6)} ${item.optString("message")}"
}.getOrElse { if (hiddenBefore == 0L) line else null }
}.joinToString("\n")
}.getOrElse { "Unable to read agent logs: ${it.message}" }
}
private fun button(context: Context, label: String, primary: Boolean, action: () -> Unit): TextView {
return TextView(context).apply {
text = label
gravity = Gravity.CENTER
textSize = 12f
setTypeface(typeface, Typeface.BOLD)
setTextColor(if (primary) Color.BLACK else textColor)
background = rounded(if (primary) primaryColor else surfaceHighColor, dp(context, 12),
if (primary) primaryColor else outlineColor)
setOnClickListener { action() }
}
}
private fun rounded(fill: Int, radius: Int, stroke: Int? = null): GradientDrawable {
return GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
setColor(fill)
cornerRadius = radius.toFloat()
if (stroke != null) setStroke(1, stroke)
}
}
private fun dp(context: Context, value: Int): Int =
(value * context.resources.displayMetrics.density).toInt()
}
@@ -1,16 +1,22 @@
package com.qm4rs.fridabox
import android.app.Activity
import android.app.Application
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import java.io.File
import top.niunaijun.blackbox.BlackBoxCore
import top.niunaijun.blackbox.app.BActivityThread
import top.niunaijun.blackbox.app.configuration.AppLifecycleCallback
import top.niunaijun.blackbox.app.configuration.ClientConfiguration
import top.niunaijun.blackbox.instrumentation.FridaGadgetLoader
import top.niunaijun.blackbox.instrumentation.GuestRuntimeRegistry
object FridaBoxRuntime {
private const val TAG = "FridaBox.Runtime"
private const val AGENT_READY_FALLBACK_MS = 5_000L
fun attach(context: Context) {
BlackBoxCore.get().doAttachBaseContext(context, object : ClientConfiguration() {
@@ -48,6 +54,19 @@ object FridaBoxRuntime {
userId: Int
) {
Log.d(TAG, "afterApplicationOnCreate: package=$packageName process=$processName")
if (GuestRuntimeRegistry.isPrimaryProcess()) {
Handler(Looper.getMainLooper()).postDelayed({
FridaGadgetLoader.loadIfEnabled()
}, AGENT_READY_FALLBACK_MS)
}
}
override fun onActivityResumed(activity: Activity) {
val packageName = runCatching { BActivityThread.getAppPackageName() }.getOrNull()
if (!packageName.isNullOrBlank()) {
FridaGadgetLoader.loadIfEnabled()
FridaBoxGuestLogOverlay.attach(activity, packageName)
}
}
override fun onStoragePermissionNeeded(packageName: String?, userId: Int): Boolean {
+2 -2
View File
@@ -10,8 +10,8 @@ android {
applicationId "com.qm4rs.fridabox.sample"
minSdk rootProject.ext.minSdk
targetSdk rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionCode 2
versionName "1.1"
}
compileOptions {
@@ -3,6 +3,7 @@ package com.qm4rs.fridabox.sample;
import android.app.Activity;
import android.os.Bundle;
import android.view.Gravity;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.TextView;
@@ -16,7 +17,12 @@ public final class MainActivity extends Activity {
output.setText("Press the button to call Target.add(2, 3)");
Button button = new Button(this);
button.setText("Call Target.add(2, 3)");
button.setOnClickListener(view -> output.setText("Result: " + Target.add(2, 3)));
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
output.setText("Result: " + Target.add(2, 3));
}
});
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setGravity(Gravity.CENTER);