From 165c014d752dc109974c64d13795de3bd41ea008 Mon Sep 17 00:00:00 2001
From: Mahdi Karzari <76250959+QM4RS@users.noreply.github.com>
Date: Mon, 20 Jul 2026 02:54:09 +0330
Subject: [PATCH] feat: ship commercial FridaBox workspace
---
Bcore/build.gradle | 1 +
.../main/aidl/android/app/IBinderSession.aidl | 5 +
.../aidl/android/app/IServiceConnection.aidl | 3 +-
.../android/app/IServiceConnectionL.java | 13 +
.../delegate/ServiceConnectionDelegate.java | 15 +-
.../instrumentation/FridaGadgetLoader.java | 14 +-
.../InstrumentationSettings.java | 57 +-
.../InstrumentationStatusStore.java | 27 +-
.../LocalScriptGadgetRuntime.java | 114 ++
.../LocalScriptGadgetRuntimeTest.java | 25 +
app/build.gradle | 26 +-
app/src/main/AndroidManifest.xml | 24 +-
.../blackboxa/fridabox/FridaBoxActivity.kt | 1109 +++++++++++++----
.../main/res/drawable/bg_fridabox_hero.xml | 6 +
app/src/main/res/drawable/ic_fb_add.xml | 4 +
app/src/main/res/drawable/ic_fb_runtime.xml | 4 +
app/src/main/res/drawable/ic_fb_settings.xml | 4 +
app/src/main/res/drawable/ic_fb_workspace.xml | 4 +
app/src/main/res/drawable/ic_fridabox_app.xml | 7 +
.../main/res/drawable/ic_fridabox_mark.xml | 6 +
app/src/main/res/layout/activity_fridabox.xml | 112 ++
app/src/main/res/menu/fridabox_navigation.xml | 6 +
.../res/values-sw600dp/fridabox_dimens.xml | 8 +
app/src/main/res/values/fridabox_colors.xml | 18 +
app/src/main/res/values/fridabox_dimens.xml | 8 +
app/src/main/res/values/fridabox_strings.xml | 59 +
app/src/main/res/values/fridabox_styles.xml | 29 +
docs/BUILDING.md | 30 +
docs/FRIDA_CONNECTION.md | 12 +
docs/TEST_RESULTS.md | 51 +
docs/USAGE.md | 68 +-
docs/device-validation.log | 104 ++
32 files changed, 1666 insertions(+), 307 deletions(-)
create mode 100644 Bcore/src/main/aidl/android/app/IBinderSession.aidl
create mode 100644 Bcore/src/main/java/black/android/app/IServiceConnectionL.java
create mode 100644 Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntime.java
create mode 100644 Bcore/src/test/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntimeTest.java
create mode 100644 app/src/main/res/drawable/bg_fridabox_hero.xml
create mode 100644 app/src/main/res/drawable/ic_fb_add.xml
create mode 100644 app/src/main/res/drawable/ic_fb_runtime.xml
create mode 100644 app/src/main/res/drawable/ic_fb_settings.xml
create mode 100644 app/src/main/res/drawable/ic_fb_workspace.xml
create mode 100644 app/src/main/res/drawable/ic_fridabox_app.xml
create mode 100644 app/src/main/res/drawable/ic_fridabox_mark.xml
create mode 100644 app/src/main/res/layout/activity_fridabox.xml
create mode 100644 app/src/main/res/menu/fridabox_navigation.xml
create mode 100644 app/src/main/res/values-sw600dp/fridabox_dimens.xml
create mode 100644 app/src/main/res/values/fridabox_colors.xml
create mode 100644 app/src/main/res/values/fridabox_dimens.xml
create mode 100644 app/src/main/res/values/fridabox_strings.xml
create mode 100644 app/src/main/res/values/fridabox_styles.xml
diff --git a/Bcore/build.gradle b/Bcore/build.gradle
index 7eb4117..832b12b 100644
--- a/Bcore/build.gradle
+++ b/Bcore/build.gradle
@@ -13,6 +13,7 @@ android {
aidlPackagedList "android/app/IServiceConnection.aidl"
+ aidlPackagedList "android/app/IBinderSession.aidl"
aidlPackagedList "android/accounts/IAccountManagerResponse.aidl"
buildFeatures {
aidl true
diff --git a/Bcore/src/main/aidl/android/app/IBinderSession.aidl b/Bcore/src/main/aidl/android/app/IBinderSession.aidl
new file mode 100644
index 0000000..4246ece
--- /dev/null
+++ b/Bcore/src/main/aidl/android/app/IBinderSession.aidl
@@ -0,0 +1,5 @@
+package android.app;
+
+/** @hide */
+interface IBinderSession {
+}
diff --git a/Bcore/src/main/aidl/android/app/IServiceConnection.aidl b/Bcore/src/main/aidl/android/app/IServiceConnection.aidl
index e312ecb..e6d70ba 100644
--- a/Bcore/src/main/aidl/android/app/IServiceConnection.aidl
+++ b/Bcore/src/main/aidl/android/app/IServiceConnection.aidl
@@ -18,9 +18,10 @@
package android.app;
import android.content.ComponentName;
+import android.app.IBinderSession;
/** @hide */
interface IServiceConnection {
- void connected(in ComponentName name, IBinder service);
+ void connected(in ComponentName name, IBinder service, IBinderSession session, boolean dead);
}
diff --git a/Bcore/src/main/java/black/android/app/IServiceConnectionL.java b/Bcore/src/main/java/black/android/app/IServiceConnectionL.java
new file mode 100644
index 0000000..425caf7
--- /dev/null
+++ b/Bcore/src/main/java/black/android/app/IServiceConnectionL.java
@@ -0,0 +1,13 @@
+package black.android.app;
+
+import android.content.ComponentName;
+import android.os.IBinder;
+
+import top.niunaijun.blackreflection.annotation.BClassName;
+import top.niunaijun.blackreflection.annotation.BMethod;
+
+@BClassName("android.app.IServiceConnection")
+public interface IServiceConnectionL {
+ @BMethod
+ void connected(ComponentName ComponentName0, IBinder IBinder1);
+}
diff --git a/Bcore/src/main/java/top/niunaijun/blackbox/fake/delegate/ServiceConnectionDelegate.java b/Bcore/src/main/java/top/niunaijun/blackbox/fake/delegate/ServiceConnectionDelegate.java
index 3da6bdb..69fcfc2 100644
--- a/Bcore/src/main/java/top/niunaijun/blackbox/fake/delegate/ServiceConnectionDelegate.java
+++ b/Bcore/src/main/java/top/niunaijun/blackbox/fake/delegate/ServiceConnectionDelegate.java
@@ -1,6 +1,7 @@
package top.niunaijun.blackbox.fake.delegate;
import android.app.IServiceConnection;
+import android.app.IBinderSession;
import android.content.ComponentName;
import android.content.Intent;
import android.os.IBinder;
@@ -9,6 +10,7 @@ import android.os.RemoteException;
import java.util.HashMap;
import java.util.Map;
+import black.android.app.BRIServiceConnectionL;
import black.android.app.BRIServiceConnectionO;
import top.niunaijun.blackbox.utils.compat.BuildCompat;
@@ -48,7 +50,6 @@ public class ServiceConnectionDelegate extends IServiceConnection.Stub {
return delegate;
}
- @Override
public void connected(ComponentName name, IBinder service) throws RemoteException {
connected(name, service, false);
}
@@ -57,7 +58,17 @@ public class ServiceConnectionDelegate extends IServiceConnection.Stub {
if (BuildCompat.isOreo()) {
BRIServiceConnectionO.get(mConn).connected(mComponentName, service, dead);
} else {
- mConn.connected(name, service);
+ BRIServiceConnectionL.get(mConn).connected(mComponentName, service);
+ }
+ }
+
+ @Override
+ public void connected(ComponentName name, IBinder service, IBinderSession session, boolean dead)
+ throws RemoteException {
+ if (android.os.Build.VERSION.SDK_INT >= 36) {
+ mConn.connected(mComponentName, service, session, dead);
+ } else {
+ connected(name, service, dead);
}
}
}
diff --git a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/FridaGadgetLoader.java b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/FridaGadgetLoader.java
index 68cb9f7..e4a3fe3 100644
--- a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/FridaGadgetLoader.java
+++ b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/FridaGadgetLoader.java
@@ -2,6 +2,7 @@ package top.niunaijun.blackbox.instrumentation;
import android.util.Log;
+import java.io.File;
import java.util.concurrent.atomic.AtomicBoolean;
/** Loads Frida Gadget at most once in the current Linux process. */
@@ -25,8 +26,17 @@ public final class FridaGadgetLoader {
if (!ATTEMPTED.compareAndSet(false, true)) return false;
try {
InstrumentationStatusStore.recordBinding();
- Log.i(TAG, "Loading Frida Gadget for " + GuestRuntimeRegistry.getGuestProcessName());
- System.loadLibrary("frida-gadget");
+ 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);
+ Log.i(TAG, "Loading on-device Frida agent for " + GuestRuntimeRegistry.getGuestProcessName());
+ System.load(runtime.getAbsolutePath());
+ } else {
+ Log.i(TAG, "Loading Frida Gadget listener for " + GuestRuntimeRegistry.getGuestProcessName());
+ System.loadLibrary("frida-gadget");
+ }
loaded = true;
InstrumentationStatusStore.recordLoaded();
Log.i(TAG, "Frida Gadget loaded");
diff --git a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/InstrumentationSettings.java b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/InstrumentationSettings.java
index be9ae63..19f9f57 100644
--- a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/InstrumentationSettings.java
+++ b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/InstrumentationSettings.java
@@ -13,6 +13,12 @@ public final class InstrumentationSettings {
public static final String KEY_SCAN_COUNT = "frida_port_scan_count";
public static final String KEY_ADVANCED_LOGS = "show_advanced_logs";
private static final String PACKAGE_PREFIX = "package_enabled_";
+ private static final String PACKAGE_MODE_PREFIX = "package_mode_";
+ private static final String PACKAGE_SCRIPT_PREFIX = "package_script_";
+
+ public static final String MODE_COMPUTER = "computer";
+ public static final String MODE_LOCAL_SCRIPT = "local_script";
+ public static final String MODE_CLEAN = "clean";
private InstrumentationSettings() {
}
@@ -27,13 +33,56 @@ public final class InstrumentationSettings {
}
public static boolean isEnabledForPackage(String packageName) {
- SharedPreferences preferences = preferences();
- return preferences.getBoolean(KEY_ENABLED, true)
- && preferences.getBoolean(PACKAGE_PREFIX + packageName, true);
+ return preferences().getBoolean(KEY_ENABLED, true)
+ && !MODE_CLEAN.equals(getModeForPackage(packageName));
}
public static void setEnabledForPackage(String packageName, boolean enabled) {
- preferences().edit().putBoolean(PACKAGE_PREFIX + packageName, enabled).commit();
+ setModeForPackage(packageName, enabled ? MODE_COMPUTER : MODE_CLEAN);
+ }
+
+ public static String getModeForPackage(String packageName) {
+ SharedPreferences preferences = preferences();
+ String mode = preferences.getString(PACKAGE_MODE_PREFIX + packageName, null);
+ if (isValidMode(mode)) return mode;
+ return preferences.getBoolean(PACKAGE_PREFIX + packageName, true)
+ ? MODE_COMPUTER : MODE_CLEAN;
+ }
+
+ public static void setModeForPackage(String packageName, String mode) {
+ String safeMode = isValidMode(mode) ? mode : MODE_COMPUTER;
+ preferences().edit()
+ .putString(PACKAGE_MODE_PREFIX + packageName, safeMode)
+ .putBoolean(PACKAGE_PREFIX + packageName, !MODE_CLEAN.equals(safeMode))
+ .commit();
+ }
+
+ public static String getScriptPathForPackage(String packageName) {
+ return preferences().getString(PACKAGE_SCRIPT_PREFIX + packageName, null);
+ }
+
+ public static void setScriptPathForPackage(String packageName, String path) {
+ SharedPreferences.Editor editor = preferences().edit();
+ if (path == null || path.trim().isEmpty()) {
+ editor.remove(PACKAGE_SCRIPT_PREFIX + packageName);
+ } else {
+ editor.putString(PACKAGE_SCRIPT_PREFIX + packageName, path);
+ }
+ editor.commit();
+ }
+
+ public static void clearPackage(String packageName) {
+ preferences().edit()
+ .remove(PACKAGE_PREFIX + packageName)
+ .remove(PACKAGE_MODE_PREFIX + packageName)
+ .remove(PACKAGE_SCRIPT_PREFIX + packageName)
+ .commit();
+ }
+
+ private static boolean isValidMode(String mode) {
+ return MODE_COMPUTER.equals(mode)
+ || MODE_LOCAL_SCRIPT.equals(mode)
+ || MODE_CLEAN.equals(mode);
}
public static int getBasePort() {
diff --git a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/InstrumentationStatusStore.java b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/InstrumentationStatusStore.java
index 1ae046e..0506504 100644
--- a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/InstrumentationStatusStore.java
+++ b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/InstrumentationStatusStore.java
@@ -17,21 +17,42 @@ public final class InstrumentationStatusStore {
public static void recordBinding() {
String packageName = GuestRuntimeRegistry.getGuestPackageName();
+ String mode = InstrumentationSettings.getModeForPackage(packageName);
+ String state;
+ if (!GuestRuntimeRegistry.isInstrumentationEnabled()) {
+ state = "disabled";
+ } else if (InstrumentationSettings.MODE_LOCAL_SCRIPT.equals(mode)) {
+ state = "loading_local_script";
+ } else {
+ state = "waiting_for_attach";
+ }
preferences().edit()
.putString("runtime_package", packageName)
.putString("runtime_process", GuestRuntimeRegistry.getGuestProcessName())
+ .putInt("runtime_user_id", GuestRuntimeRegistry.getGuestUserId())
.putInt("runtime_vpid", GuestRuntimeRegistry.getVirtualProcessId())
.putString("runtime_source", GuestRuntimeRegistry.getGuestSourceDir())
+ .putString("runtime_class_loader", classLoaderDescription())
.putBoolean("runtime_enabled", GuestRuntimeRegistry.isInstrumentationEnabled())
- .putString("runtime_state", GuestRuntimeRegistry.isInstrumentationEnabled()
- ? "waiting_for_attach" : "disabled")
+ .putString("runtime_mode", mode)
+ .putString("runtime_script", InstrumentationSettings.getScriptPathForPackage(packageName))
+ .putString("runtime_state", state)
.putString("runtime_error", null)
.putLong("runtime_timestamp", GuestRuntimeRegistry.getInitializationTimestamp())
.commit();
}
+ private static String classLoaderDescription() {
+ ClassLoader loader = GuestRuntimeRegistry.getGuestClassLoader();
+ if (loader == null) return null;
+ return loader.getClass().getName() + "@" + Integer.toHexString(System.identityHashCode(loader));
+ }
+
public static void recordLoaded() {
- preferences().edit().putString("runtime_state", "loaded").putString("runtime_error", null).commit();
+ String mode = preferences().getString("runtime_mode", InstrumentationSettings.MODE_COMPUTER);
+ String state = InstrumentationSettings.MODE_LOCAL_SCRIPT.equals(mode)
+ ? "local_script_active" : "computer_attached";
+ preferences().edit().putString("runtime_state", state).putString("runtime_error", null).commit();
}
public static void recordError(String error) {
diff --git a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntime.java b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntime.java
new file mode 100644
index 0000000..c4b0acd
--- /dev/null
+++ b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntime.java
@@ -0,0 +1,114 @@
+package top.niunaijun.blackbox.instrumentation;
+
+import android.content.Context;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import top.niunaijun.blackbox.BlackBoxCore;
+
+/** Prepares a private Gadget copy configured to autonomously load one guest agent. */
+final class LocalScriptGadgetRuntime {
+ private static final String AGENT_ROOT = "fridabox-agents";
+ private static final String AGENT_NAME = "agent.js";
+ private static final String RUNTIME_NAME = "libfridabox-agent.so";
+ private static final String CONFIG_NAME = "libfridabox-agent.config.so";
+
+ private LocalScriptGadgetRuntime() {
+ }
+
+ static File prepare(String packageName, String scriptPath) throws IOException {
+ Context context = BlackBoxCore.getContext();
+ if (scriptPath == null || scriptPath.trim().isEmpty()) {
+ throw new IOException("No on-device JavaScript agent is selected");
+ }
+ File root = new File(context.getFilesDir(), AGENT_ROOT).getCanonicalFile();
+ File script = new File(scriptPath).getCanonicalFile();
+ if (!isInside(root, script) || !AGENT_NAME.equals(script.getName()) || !script.isFile()) {
+ throw new IOException("Selected JavaScript agent is outside FridaBox private storage");
+ }
+ if (!script.setReadable(true, true) || !script.setWritable(false, false)) {
+ throw new IOException("Unable to secure the selected JavaScript agent");
+ }
+
+ File directory = script.getParentFile();
+ File source = new File(context.getApplicationInfo().nativeLibraryDir, "libfrida-gadget.so");
+ if (!source.isFile()) throw new IOException("Packaged Frida Gadget is missing");
+
+ File runtime = new File(directory, RUNTIME_NAME);
+ if (!runtime.isFile() || runtime.length() != source.length()) {
+ copyAtomically(source, runtime);
+ }
+ if (!runtime.setReadable(true, false)
+ || !runtime.setExecutable(true, false)
+ || !runtime.setWritable(false, false)) {
+ throw new IOException("Unable to secure private Frida Gadget permissions");
+ }
+
+ File config = new File(directory, CONFIG_NAME);
+ writeUtf8Atomically(config, buildConfig(packageName));
+ return runtime;
+ }
+
+ static String buildConfig(String packageName) {
+ return "{\n" +
+ " \"interaction\": {\n" +
+ " \"type\": \"script\",\n" +
+ " \"path\": \"" + AGENT_NAME + "\",\n" +
+ " \"on_change\": \"reload\",\n" +
+ " \"parameters\": { \"package\": \"" + json(packageName) + "\" }\n" +
+ " },\n" +
+ " \"runtime\": \"qjs\",\n" +
+ " \"teardown\": \"minimal\"\n" +
+ "}\n";
+ }
+
+ static boolean isInside(File root, File child) {
+ String rootPath = root.getAbsolutePath();
+ String childPath = child.getAbsolutePath();
+ return childPath.startsWith(rootPath + File.separator);
+ }
+
+ private static String json(String value) {
+ if (value == null) return "";
+ return value.replace("\\", "\\\\").replace("\"", "\\\"")
+ .replace("\n", "\\n").replace("\r", "\\r");
+ }
+
+ private static void copyAtomically(File source, File destination) throws IOException {
+ File temporary = new File(destination.getParentFile(), destination.getName() + ".partial");
+ if (temporary.exists() && !temporary.delete()) throw new IOException("Unable to replace temporary Gadget");
+ try (FileInputStream input = new FileInputStream(source);
+ FileOutputStream output = new FileOutputStream(temporary)) {
+ byte[] buffer = new byte[128 * 1024];
+ int count;
+ while ((count = input.read(buffer)) >= 0) output.write(buffer, 0, count);
+ output.getFD().sync();
+ }
+ replace(temporary, destination);
+ }
+
+ private static void writeUtf8Atomically(File destination, String value) throws IOException {
+ byte[] expected = value.getBytes(StandardCharsets.UTF_8);
+ if (destination.isFile() && destination.length() == expected.length) {
+ byte[] current = new byte[expected.length];
+ try (FileInputStream input = new FileInputStream(destination)) {
+ if (input.read(current) == current.length && java.util.Arrays.equals(current, expected)) return;
+ }
+ }
+ File temporary = new File(destination.getParentFile(), destination.getName() + ".partial");
+ try (FileOutputStream output = new FileOutputStream(temporary)) {
+ output.write(expected);
+ output.getFD().sync();
+ }
+ replace(temporary, destination);
+ }
+
+ 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());
+ }
+}
diff --git a/Bcore/src/test/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntimeTest.java b/Bcore/src/test/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntimeTest.java
new file mode 100644
index 0000000..3f10096
--- /dev/null
+++ b/Bcore/src/test/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntimeTest.java
@@ -0,0 +1,25 @@
+package top.niunaijun.blackbox.instrumentation;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.io.File;
+
+public class LocalScriptGadgetRuntimeTest {
+ @Test
+ public void configUsesAutonomousScriptInteraction() {
+ String config = LocalScriptGadgetRuntime.buildConfig("sample.\"guest");
+ assertTrue(config.contains("\"type\": \"script\""));
+ assertTrue(config.contains("\"path\": \"agent.js\""));
+ assertTrue(config.contains("sample.\\\"guest"));
+ }
+
+ @Test
+ public void privatePathCheckRejectsSiblingPrefix() {
+ File root = new File("/data/user/0/host/files/fridabox-agents");
+ assertTrue(LocalScriptGadgetRuntime.isInside(root, new File(root, "guest/agent.js")));
+ assertFalse(LocalScriptGadgetRuntime.isInside(root, new File(root.getPath() + "-other/agent.js")));
+ }
+}
diff --git a/app/build.gradle b/app/build.gradle
index fb8fb77..2736ba1 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -3,6 +3,16 @@ plugins {
alias(libs.plugins.jetbrains.kotlin.android)
}
+def releaseStoreFile = System.getenv("FRIDABOX_RELEASE_STORE_FILE")
+def releaseStorePassword = System.getenv("FRIDABOX_RELEASE_STORE_PASSWORD")
+def releaseKeyAlias = System.getenv("FRIDABOX_RELEASE_KEY_ALIAS")
+def releaseKeyPassword = System.getenv("FRIDABOX_RELEASE_KEY_PASSWORD")
+def releaseSigningValues = [releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword]
+def releaseSigningConfigured = releaseSigningValues.every { it != null && !it.trim().isEmpty() }
+if (releaseSigningValues.any { it != null && !it.trim().isEmpty() } && !releaseSigningConfigured) {
+ throw new GradleException("Release signing is incomplete. Set all four FRIDABOX_RELEASE_* environment variables.")
+}
+
android {
namespace 'top.niunaijun.blackboxa'
@@ -28,10 +38,24 @@ android {
}
}
+ signingConfigs {
+ if (releaseSigningConfigured) {
+ release {
+ storeFile file(releaseStoreFile)
+ storePassword releaseStorePassword
+ keyAlias releaseKeyAlias
+ keyPassword releaseKeyPassword
+ }
+ }
+ }
+
buildTypes {
release {
- signingConfig signingConfigs.debug
+ if (releaseSigningConfigured) {
+ signingConfig signingConfigs.release
+ }
minifyEnabled true
+ shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 477102b..fc594fb 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -17,18 +17,24 @@
android:allowBackup="false"
android:extractNativeLibs="true"
android:fullBackupContent="false"
- android:icon="@mipmap/ic_launcher"
+ android:icon="@drawable/ic_fridabox_app"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
- android:roundIcon="@mipmap/ic_launcher_round"
+ android:roundIcon="@drawable/ic_fridabox_app"
android:supportsRtl="true"
- android:theme="@style/Theme.BlackBox"
+ android:theme="@style/Theme.FridaBox"
android:enableOnBackInvokedCallback="true"
tools:replace="android:allowBackup"
tools:targetApi="n">
+ android:exported="true"
+ android:launchMode="singleTop">
+
+
+
+
+
@@ -36,15 +42,9 @@
-
-
-
-
-
-
+ android:theme="@style/Theme.FridaBox" />
diff --git a/app/src/main/java/top/niunaijun/blackboxa/fridabox/FridaBoxActivity.kt b/app/src/main/java/top/niunaijun/blackboxa/fridabox/FridaBoxActivity.kt
index 9315288..2909a81 100644
--- a/app/src/main/java/top/niunaijun/blackboxa/fridabox/FridaBoxActivity.kt
+++ b/app/src/main/java/top/niunaijun/blackboxa/fridabox/FridaBoxActivity.kt
@@ -1,48 +1,66 @@
package top.niunaijun.blackboxa.fridabox
-import android.app.AlertDialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
-import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
+import android.content.res.ColorStateList
import android.graphics.Typeface
+import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import android.text.InputType
import android.view.Gravity
+import android.view.Menu
import android.view.View
import android.view.ViewGroup
-import android.widget.Button
-import android.widget.CheckBox
-import android.widget.EditText
-import android.widget.HorizontalScrollView
import android.widget.ImageView
import android.widget.LinearLayout
-import android.widget.ScrollView
-import android.widget.Switch
+import android.widget.PopupMenu
+import android.widget.Space
import android.widget.TextView
import android.widget.Toast
+import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import androidx.core.view.isVisible
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.button.MaterialButtonToggleGroup
+import com.google.android.material.card.MaterialCardView
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.snackbar.Snackbar
+import com.google.android.material.switchmaterial.SwitchMaterial
+import com.google.android.material.textfield.TextInputEditText
+import com.google.android.material.textfield.TextInputLayout
import top.niunaijun.blackbox.BlackBoxCore
import top.niunaijun.blackbox.instrumentation.InstrumentationSettings
import top.niunaijun.blackboxa.BuildConfig
+import top.niunaijun.blackboxa.R
+import top.niunaijun.blackboxa.databinding.ActivityFridaboxBinding
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.Locale
import java.util.concurrent.Executors
-/** Minimal host UI for APK import, virtual launch, runtime status, and settings. */
+/** Product workspace for importing, configuring, and launching FridaBox guests. */
class FridaBoxActivity : AppCompatActivity() {
+ private enum class Screen { WORKSPACE, RUNTIME, SETTINGS }
+
+ private lateinit var binding: ActivityFridaboxBinding
private val worker = Executors.newSingleThreadExecutor()
- private lateinit var content: LinearLayout
+ private var screen = Screen.WORKSPACE
+ private var screenGeneration = 0
+ private var changingNavigation = false
+ private var pendingScriptPackage: String? = null
+
+ @Suppress("DEPRECATION")
private val settings: SharedPreferences by lazy {
- getSharedPreferences(InstrumentationSettings.PREFERENCES, Context.MODE_PRIVATE)
+ getSharedPreferences(InstrumentationSettings.PREFERENCES, Context.MODE_MULTI_PROCESS)
}
private val metadata: SharedPreferences by lazy {
getSharedPreferences("fridabox_imports", Context.MODE_PRIVATE)
@@ -51,10 +69,40 @@ class FridaBoxActivity : AppCompatActivity() {
private val apkPicker = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) importApk(uri)
}
+ private val scriptPicker = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ val packageName = pendingScriptPackage
+ pendingScriptPackage = null
+ if (uri != null && packageName != null) importAgent(packageName, uri)
+ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- showHome()
+ binding = ActivityFridaboxBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ pendingScriptPackage = savedInstanceState?.getString("pending_script_package")
+
+ binding.importFab.setOnClickListener { openApkPicker() }
+ binding.bottomNavigation.setOnItemSelectedListener { item ->
+ if (changingNavigation) return@setOnItemSelectedListener true
+ when (item.itemId) {
+ R.id.nav_workspace -> showWorkspace()
+ R.id.nav_runtime -> showRuntime()
+ R.id.nav_settings -> showSettings()
+ else -> return@setOnItemSelectedListener false
+ }
+ true
+ }
+ onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (screen == Screen.WORKSPACE) finish() else showWorkspace()
+ }
+ })
+ showWorkspace()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ outState.putString("pending_script_package", pendingScriptPackage)
+ super.onSaveInstanceState(outState)
}
override fun onDestroy() {
@@ -62,196 +110,470 @@ class FridaBoxActivity : AppCompatActivity() {
super.onDestroy()
}
- private fun baseScreen(title: String): LinearLayout {
- val root = LinearLayout(this).apply {
- orientation = LinearLayout.VERTICAL
- setPadding(dp(16), dp(12), dp(16), dp(12))
- setBackgroundColor(0xfff7f8fa.toInt())
+ private fun showWorkspace() {
+ screen = Screen.WORKSPACE
+ val generation = resetScreen(R.id.nav_workspace, showImport = false)
+ binding.toolbar.title = getString(R.string.fb_brand)
+ binding.toolbar.subtitle = getString(R.string.fb_brand_tagline)
+
+ binding.content.addView(heroCard())
+ binding.content.requestFocus()
+ binding.contentScroll.post {
+ binding.content.requestFocus()
+ binding.contentScroll.scrollTo(0, 0)
}
- val header = LinearLayout(this).apply {
+ val titleRow = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ setPadding(0, dp(28), 0, dp(10))
+ }
+ titleRow.addView(verticalText(
+ getString(R.string.fb_guest_workspace),
+ getString(R.string.fb_guest_workspace_hint)
+ ), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
+ val count = badge("…", color(R.color.fb_surface_high), color(R.color.fb_primary))
+ titleRow.addView(count)
+ binding.content.addView(titleRow)
+ binding.content.addView(primaryButton(getString(R.string.fb_import_apk)) {
+ openApkPicker()
+ }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48)).apply {
+ bottomMargin = dp(14)
+ })
+
+ val guestList = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
+ binding.content.addView(guestList)
+ setLoading(true)
+ worker.execute {
+ val result = runCatching {
+ BlackBoxCore.get().getInstalledPackages(PackageManager.GET_META_DATA, 0)
+ .sortedBy { it.packageName }
+ }
+ runOnUiThread {
+ if (generation != screenGeneration || isFinishing) return@runOnUiThread
+ setLoading(false)
+ result.onSuccess { packages ->
+ count.text = packages.size.toString()
+ if (packages.isEmpty()) guestList.addView(emptyWorkspace())
+ else packages.forEach { guestList.addView(appCard(it)) }
+ }.onFailure { error ->
+ guestList.addView(messageCard(
+ "Workspace unavailable",
+ error.message ?: "Unable to read virtual applications",
+ R.color.fb_error
+ ))
+ }
+ }
+ }
+ }
+
+ private fun heroCard(): View {
+ val body = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(22), dp(22), dp(22), dp(22))
+ background = ContextCompat.getDrawable(this@FridaBoxActivity, R.drawable.bg_fridabox_hero)
+ }
+ body.addView(labelText(getString(R.string.fb_hero_eyebrow), R.color.fb_primary, 11f, true))
+ body.addView(labelText(getString(R.string.fb_hero_title), R.color.fb_text_primary, 29f, true).apply {
+ setPadding(0, dp(8), 0, 0)
+ })
+ body.addView(labelText(getString(R.string.fb_hero_body), R.color.fb_text_secondary, 15f, false).apply {
+ setPadding(0, dp(10), 0, 0)
+ setLineSpacing(0f, 1.15f)
+ })
+ val signals = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.START
+ setPadding(0, dp(18), 0, 0)
+ }
+ signals.addView(badge("ARM64", color(R.color.fb_surface_tint), color(R.color.fb_primary)))
+ signals.addView(space(dp(8), 1))
+ signals.addView(badge("v${BuildConfig.VERSION_NAME}", color(R.color.fb_surface_tint), color(R.color.fb_text_primary)))
+ body.addView(signals)
+ return body
+ }
+
+ private fun emptyWorkspace(): View {
+ return surfaceCard().apply {
+ addView(LinearLayout(this@FridaBoxActivity).apply {
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER_HORIZONTAL
+ setPadding(dp(24), dp(36), dp(24), dp(36))
+ addView(ImageView(this@FridaBoxActivity).apply {
+ setImageResource(R.drawable.ic_fridabox_mark)
+ imageTintList = ColorStateList.valueOf(color(R.color.fb_text_secondary))
+ }, LinearLayout.LayoutParams(dp(52), dp(52)))
+ addView(labelText(getString(R.string.fb_no_guests), R.color.fb_text_primary, 19f, true).apply {
+ setPadding(0, dp(16), 0, 0)
+ })
+ addView(labelText(getString(R.string.fb_no_guests_body), R.color.fb_text_secondary, 14f, false).apply {
+ gravity = Gravity.CENTER
+ setPadding(0, dp(8), 0, dp(18))
+ })
+ addView(primaryButton(getString(R.string.fb_import_apk)) { openApkPicker() })
+ })
+ }
+ }
+
+ private fun appCard(info: PackageInfo): View {
+ val packageName = info.packageName
+ val mode = InstrumentationSettings.getModeForPackage(packageName)
+ val appLabel = runCatching {
+ info.applicationInfo?.loadLabel(BlackBoxCore.getPackageManager())?.toString()
+ }.getOrNull().orEmpty().ifBlank { packageName.substringAfterLast('.') }
+
+ val card = surfaceCard().apply {
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply { bottomMargin = dp(14) }
+ }
+ val body = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(18), dp(18), dp(18), dp(18))
+ }
+ card.addView(body)
+
+ val heading = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
}
- header.addView(TextView(this).apply {
- text = title
- textSize = 24f
- setTypeface(typeface, Typeface.BOLD)
- }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
- header.addView(Button(this).apply {
- text = "Home"
- setOnClickListener { showHome() }
- })
- header.addView(Button(this).apply {
- text = "Runtime"
- setOnClickListener { showRuntime() }
- })
- header.addView(Button(this).apply {
- text = "Settings"
- setOnClickListener { showSettings() }
- })
- root.addView(header)
- content = LinearLayout(this).apply {
- orientation = LinearLayout.VERTICAL
- setPadding(0, dp(12), 0, dp(24))
+ val icon = ImageView(this).apply {
+ runCatching { setImageDrawable(info.applicationInfo?.loadIcon(BlackBoxCore.getPackageManager())) }
+ background = rounded(color(R.color.fb_surface_tint), dp(16))
+ setPadding(dp(8), dp(8), dp(8), dp(8))
}
- root.addView(ScrollView(this).apply { addView(content) }, LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
- setContentView(root)
- return content
+ heading.addView(icon, LinearLayout.LayoutParams(dp(58), dp(58)))
+ heading.addView(verticalText(
+ appLabel,
+ "$packageName · ${info.versionName ?: "—"}"
+ ).apply { setPadding(dp(13), 0, dp(8), 0) }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
+ val modeBadge = badge(modeShortLabel(mode), modeColor(mode, true), modeColor(mode, false))
+ heading.addView(modeBadge)
+ body.addView(heading)
+
+ body.addView(labelText("Launch mode", R.color.fb_text_secondary, 12f, true).apply {
+ setPadding(0, dp(18), 0, dp(7))
+ })
+ val modeInfo = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
+ val group = MaterialButtonToggleGroup(this).apply {
+ isSingleSelection = true
+ isSelectionRequired = true
+ }
+ val localId = View.generateViewId()
+ val computerId = View.generateViewId()
+ val cleanId = View.generateViewId()
+ group.addView(modeButton(localId, getString(R.string.fb_mode_local)))
+ group.addView(modeButton(computerId, getString(R.string.fb_mode_computer)))
+ group.addView(modeButton(cleanId, getString(R.string.fb_mode_clean)))
+ group.check(when (mode) {
+ InstrumentationSettings.MODE_LOCAL_SCRIPT -> localId
+ InstrumentationSettings.MODE_CLEAN -> cleanId
+ else -> computerId
+ })
+ group.addOnButtonCheckedListener { _, checkedId, isChecked ->
+ if (!isChecked) return@addOnButtonCheckedListener
+ val selected = when (checkedId) {
+ localId -> InstrumentationSettings.MODE_LOCAL_SCRIPT
+ cleanId -> InstrumentationSettings.MODE_CLEAN
+ else -> InstrumentationSettings.MODE_COMPUTER
+ }
+ InstrumentationSettings.setModeForPackage(packageName, selected)
+ modeBadge.text = modeShortLabel(selected)
+ modeBadge.backgroundTintList = ColorStateList.valueOf(modeColor(selected, true))
+ modeBadge.setTextColor(modeColor(selected, false))
+ renderModeInfo(modeInfo, packageName, selected)
+ }
+ body.addView(group, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48)))
+ body.addView(modeInfo)
+ renderModeInfo(modeInfo, packageName, mode)
+
+ val actions = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ setPadding(0, dp(16), 0, 0)
+ }
+ actions.addView(primaryButton(getString(R.string.fb_launch)) {
+ launchConfigured(packageName)
+ }, LinearLayout.LayoutParams(0, dp(50), 1f))
+ actions.addView(space(dp(10), 1))
+ actions.addView(outlineButton(getString(R.string.fb_more)) { anchor ->
+ showAppMenu(anchor, info, appLabel)
+ }, LinearLayout.LayoutParams(0, dp(50), 0.56f))
+ body.addView(actions)
+ return card
}
- private fun showHome() {
- baseScreen("FridaBox")
- content.addView(TextView(this).apply {
- text = "Non-root Android application virtualization with per-guest Frida Gadget instrumentation."
- textSize = 16f
+ private fun renderModeInfo(container: LinearLayout, packageName: String, mode: String) {
+ container.removeAllViews()
+ val title: String
+ val description: String
+ when (mode) {
+ InstrumentationSettings.MODE_LOCAL_SCRIPT -> {
+ title = getString(R.string.fb_mode_local_title)
+ description = getString(R.string.fb_mode_local_body)
+ }
+ InstrumentationSettings.MODE_CLEAN -> {
+ title = getString(R.string.fb_mode_clean_title)
+ description = getString(R.string.fb_mode_clean_body)
+ }
+ else -> {
+ title = getString(R.string.fb_mode_computer_title)
+ description = getString(R.string.fb_mode_computer_body)
+ }
+ }
+ val panel = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(13), dp(12), dp(13), dp(12))
+ background = rounded(color(R.color.fb_surface_tint), dp(14))
+ }
+ panel.addView(labelText(title, R.color.fb_text_primary, 14f, true))
+ panel.addView(labelText(description, R.color.fb_text_secondary, 12.5f, false).apply {
+ setPadding(0, dp(4), 0, 0)
})
- content.addView(Button(this).apply {
- text = "Import APK"
- setOnClickListener { apkPicker.launch(arrayOf("application/vnd.android.package-archive", "application/octet-stream")) }
- })
- if (BuildConfig.DEBUG) {
- content.addView(Button(this).apply {
- text = "Install demo guest"
- setOnClickListener { installDemoGuest() }
+ if (mode == InstrumentationSettings.MODE_LOCAL_SCRIPT) {
+ val scriptName = metadata.getString("$packageName.scriptName", null)
+ val scriptHash = metadata.getString("$packageName.scriptSha", null)
+ panel.addView(labelText(
+ scriptName ?: getString(R.string.fb_no_script),
+ if (scriptName == null) R.color.fb_warning else R.color.fb_success,
+ 12.5f,
+ true
+ ).apply { setPadding(0, dp(10), 0, 0) })
+ if (scriptHash != null) {
+ panel.addView(labelText("SHA-256 ${scriptHash.take(16)}…", R.color.fb_text_secondary, 11f, false))
+ }
+ panel.addView(outlineButton(
+ if (scriptName == null) getString(R.string.fb_select_script) else getString(R.string.fb_replace_script)
+ ) { chooseAgent(packageName) }.apply {
+ layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(44)).apply {
+ topMargin = dp(10)
+ }
})
}
- content.addView(sectionTitle("Virtual applications"))
- refreshApps()
+ container.addView(panel, LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply { topMargin = dp(10) })
}
- private fun refreshApps() {
- val marker = TextView(this).apply { text = "Loading…" }
- content.addView(marker)
+ private fun launchConfigured(packageName: String) {
+ val mode = InstrumentationSettings.getModeForPackage(packageName)
+ if (mode == InstrumentationSettings.MODE_LOCAL_SCRIPT) {
+ val path = InstrumentationSettings.getScriptPathForPackage(packageName)
+ if (path.isNullOrBlank() || !File(path).isFile) {
+ chooseAgent(packageName)
+ return
+ }
+ launch(packageName, mode)
+ return
+ }
+ if (mode == InstrumentationSettings.MODE_COMPUTER) {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.fb_computer_launch_title)
+ .setMessage(R.string.fb_computer_launch_body)
+ .setPositiveButton(R.string.fb_launch) { _, _ -> launch(packageName, mode) }
+ .setNegativeButton(R.string.fb_cancel, null)
+ .show()
+ return
+ }
+ launch(packageName, mode)
+ }
+
+ private fun launch(packageName: String, mode: String) {
+ setLoading(true)
worker.execute {
- val packages = try {
- BlackBoxCore.get().getInstalledPackages(PackageManager.GET_META_DATA, 0)
- } catch (error: Throwable) {
- runOnUiThread { marker.text = "Unable to read virtual packages: ${error.message}" }
- return@execute
+ val result = runCatching {
+ InstrumentationSettings.setModeForPackage(packageName, mode)
+ BlackBoxCore.get().stopPackage(packageName, 0)
+ Thread.sleep(180)
+ BlackBoxCore.get().launchApk(packageName, 0)
}
runOnUiThread {
- content.removeView(marker)
- if (packages.isEmpty()) {
- content.addView(TextView(this).apply { text = "No APKs imported yet." })
- } else {
- packages.sortedBy { it.packageName }.forEach { addAppCard(it) }
- }
+ setLoading(false)
+ result.onSuccess { launched ->
+ if (!launched) toast("This guest has no launchable activity")
+ else if (mode == InstrumentationSettings.MODE_COMPUTER) showRuntime(packageName)
+ }.onFailure { toast("Launch failed: ${it.message}") }
}
}
}
- private fun addAppCard(info: PackageInfo) {
- val card = LinearLayout(this).apply {
- orientation = LinearLayout.VERTICAL
- setPadding(dp(12), dp(12), dp(12), dp(12))
- setBackgroundColor(0xffffffff.toInt())
- }
- val heading = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL }
- heading.addView(ImageView(this).apply {
- try { setImageDrawable(info.applicationInfo?.loadIcon(BlackBoxCore.getPackageManager())) } catch (_: Throwable) { }
- }, LinearLayout.LayoutParams(dp(56), dp(56)))
- heading.addView(TextView(this).apply {
- text = buildString {
- append(info.packageName)
- append("\nVersion: ").append(info.versionName ?: "unknown")
- append("\nInstrumentation: ")
- append(if (settings.getBoolean("package_enabled_${info.packageName}", true)) "enabled" else "disabled")
+ private fun chooseAgent(packageName: String) {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.fb_choose_trusted_title)
+ .setMessage(R.string.fb_choose_trusted_body)
+ .setPositiveButton(R.string.fb_choose) { _, _ ->
+ pendingScriptPackage = packageName
+ scriptPicker.launch(arrayOf(
+ "application/javascript",
+ "text/javascript",
+ "text/plain",
+ "application/octet-stream"
+ ))
}
- setPadding(dp(12), 0, 0, 0)
- }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
- card.addView(heading)
- val actions = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL }
- actions.addView(actionButton("Launch instrumented") { confirmInstrumentedLaunch(info.packageName) })
- actions.addView(actionButton("Launch without instrumentation") { launch(info.packageName, false) })
- actions.addView(actionButton("Clear virtual app data") { clearApp(info.packageName) })
- actions.addView(actionButton("Remove from virtual space") { removeApp(info.packageName) })
- actions.addView(actionButton("View runtime details") { showRuntime(info.packageName) })
- card.addView(HorizontalScrollView(this).apply { addView(actions) })
- val sha = metadata.getString("${info.packageName}.sha256", null)
- if (sha != null) {
- card.addView(TextView(this).apply {
- text = "SHA-256: $sha\nSource: ${metadata.getString("${info.packageName}.source", "unknown")}\n" +
- "ABI: ${metadata.getString("${info.packageName}.abi", "unknown")}\n" +
- "Target SDK: ${metadata.getInt("${info.packageName}.targetSdk", info.applicationInfo?.targetSdkVersion ?: -1)}"
- textSize = 12f
- setTextIsSelectable(true)
- })
- }
- content.addView(card, LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(10) })
- }
-
- private fun confirmInstrumentedLaunch(packageName: String) {
- AlertDialog.Builder(this)
- .setTitle("Early instrumentation")
- .setMessage("Guest startup is paused until Frida attaches. Run the generated attach command or disable instrumentation.")
- .setPositiveButton("Launch") { _, _ -> launch(packageName, true) }
- .setNegativeButton("Cancel", null)
+ .setNegativeButton(R.string.fb_cancel, null)
.show()
}
- private fun launch(packageName: String, instrumented: Boolean) {
+ private fun importAgent(packageName: String, uri: Uri) {
+ val name = displayName(uri, "agent.js")
+ if (!name.lowercase(Locale.ROOT).endsWith(".js")) {
+ notify("Select a JavaScript file ending in .js")
+ return
+ }
+ setLoading(true)
worker.execute {
- try {
- InstrumentationSettings.setEnabledForPackage(packageName, instrumented)
- BlackBoxCore.get().stopPackage(packageName, 0)
- Thread.sleep(150)
- val launched = BlackBoxCore.get().launchApk(packageName, 0)
- runOnUiThread {
- toast(if (launched) "Guest launch requested" else "Guest has no launchable activity")
- if (instrumented) showRuntime(packageName)
+ val result = runCatching {
+ val directory = agentDirectory(packageName).apply { mkdirs() }
+ val temporary = File(directory, "agent.js.partial")
+ val destination = File(directory, "agent.js")
+ val digest = MessageDigest.getInstance("SHA-256")
+ var total = 0L
+ contentResolver.openInputStream(uri).use { input ->
+ requireNotNull(input) { "Unable to open the selected JavaScript" }
+ FileOutputStream(temporary).use { output ->
+ val buffer = ByteArray(64 * 1024)
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ total += count
+ if (total > MAX_AGENT_SIZE) error("JavaScript agent exceeds the 16 MiB limit")
+ digest.update(buffer, 0, count)
+ output.write(buffer, 0, count)
+ }
+ output.fd.sync()
+ }
+ }
+ if (total == 0L) error("JavaScript agent is empty")
+ if (destination.exists() && !destination.delete()) error("Unable to replace the previous agent")
+ if (!temporary.renameTo(destination)) error("Unable to store the selected agent")
+ if (!destination.setReadable(true, true) || !destination.setWritable(false, false)) {
+ error("Unable to secure the selected agent")
+ }
+ val sha = digest.digest().joinToString("") { "%02x".format(it) }
+ InstrumentationSettings.setScriptPathForPackage(packageName, destination.absolutePath)
+ InstrumentationSettings.setModeForPackage(packageName, InstrumentationSettings.MODE_LOCAL_SCRIPT)
+ metadata.edit()
+ .putString("$packageName.scriptName", name)
+ .putString("$packageName.scriptSha", sha)
+ .putLong("$packageName.scriptSize", total)
+ .apply()
+ BlackBoxCore.get().stopPackage(packageName, 0)
+ name
+ }
+ runOnUiThread {
+ setLoading(false)
+ result.onSuccess {
+ notify("$it is ready for on-device launch")
+ showWorkspace()
+ }.onFailure { error ->
+ File(agentDirectory(packageName), "agent.js.partial").delete()
+ notify("Agent import failed: ${error.message}")
}
- } catch (error: Throwable) {
- runOnUiThread { toast("Launch failed: ${error.message}") }
}
}
}
+ private fun showAppMenu(anchor: View, info: PackageInfo, appLabel: String) {
+ PopupMenu(this, anchor).apply {
+ menu.add(Menu.NONE, MENU_DETAILS, 0, R.string.fb_details)
+ menu.add(Menu.NONE, MENU_RUNTIME, 1, R.string.fb_runtime_details)
+ menu.add(Menu.NONE, MENU_CLEAR, 2, R.string.fb_clear_data)
+ menu.add(Menu.NONE, MENU_REMOVE, 3, R.string.fb_remove_guest)
+ setOnMenuItemClickListener { item ->
+ when (item.itemId) {
+ MENU_DETAILS -> showAppDetails(info, appLabel)
+ MENU_RUNTIME -> showRuntime(info.packageName)
+ MENU_CLEAR -> clearApp(info.packageName)
+ MENU_REMOVE -> removeApp(info.packageName, appLabel)
+ }
+ true
+ }
+ show()
+ }
+ }
+
+ private fun showAppDetails(info: PackageInfo, appLabel: String) {
+ val packageName = info.packageName
+ val details = buildString {
+ append(appLabel).append('\n').append(packageName)
+ append("\n\nVersion ").append(info.versionName ?: "—")
+ append("\nTarget SDK ").append(metadata.getInt("$packageName.targetSdk", info.applicationInfo?.targetSdkVersion ?: -1))
+ append("\nABI ").append(metadata.getString("$packageName.abi", "Unknown"))
+ append("\n\nSource\n").append(metadata.getString("$packageName.source", "Unknown"))
+ append("\n\nAPK SHA-256\n").append(metadata.getString("$packageName.sha256", "Unknown"))
+ }
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.fb_details)
+ .setMessage(details)
+ .setPositiveButton(android.R.string.ok, null)
+ .show()
+ }
+
private fun clearApp(packageName: String) {
+ setLoading(true)
worker.execute {
- try {
+ val result = runCatching {
BlackBoxCore.get().stopPackage(packageName, 0)
BlackBoxCore.get().clearPackage(packageName, 0)
- runOnUiThread { toast("Virtual app data cleared") }
- } catch (error: Throwable) {
- runOnUiThread { toast("Clear failed: ${error.message}") }
+ }
+ runOnUiThread {
+ setLoading(false)
+ result.onSuccess { notify("Guest data cleared") }
+ .onFailure { notify("Unable to clear guest data: ${it.message}") }
}
}
}
- private fun removeApp(packageName: String) {
- AlertDialog.Builder(this).setTitle("Remove $packageName?")
- .setMessage("This removes the app and its data only from BlackBox virtual space.")
- .setPositiveButton("Remove") { _, _ ->
+ private fun removeApp(packageName: String, appLabel: String) {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(getString(R.string.fb_remove_title))
+ .setMessage("$appLabel\n\n${getString(R.string.fb_remove_body)}")
+ .setPositiveButton(R.string.fb_remove) { _, _ ->
+ setLoading(true)
worker.execute {
- try {
+ val result = runCatching {
BlackBoxCore.get().stopPackage(packageName, 0)
BlackBoxCore.get().uninstallPackageAsUser(packageName, 0)
- runOnUiThread { showHome() }
- } catch (error: Throwable) {
- runOnUiThread { toast("Remove failed: ${error.message}") }
+ deleteAgentDirectory(packageName)
+ InstrumentationSettings.clearPackage(packageName)
+ metadata.edit()
+ .remove("$packageName.scriptName")
+ .remove("$packageName.scriptSha")
+ .remove("$packageName.scriptSize")
+ .apply()
+ }
+ runOnUiThread {
+ setLoading(false)
+ result.onSuccess { showWorkspace() }
+ .onFailure { notify("Unable to remove guest: ${it.message}") }
}
}
- }.setNegativeButton("Cancel", null).show()
+ }
+ .setNegativeButton(R.string.fb_cancel, null)
+ .show()
+ }
+
+ private fun openApkPicker() {
+ apkPicker.launch(arrayOf("application/vnd.android.package-archive", "application/octet-stream"))
}
private fun importApk(uri: Uri) {
- val name = displayName(uri)
+ val name = displayName(uri, "selected.apk")
val lowerName = name.lowercase(Locale.ROOT)
if (!lowerName.endsWith(".apk") || lowerName.endsWith(".apks") ||
lowerName.endsWith(".xapk") || lowerName.endsWith(".apkm")) {
- toast("Select one base .apk file; bundles and split sets are not supported")
+ notify("Select one base .apk file; app bundles and split sets are not supported")
return
}
- toast("Importing $name…")
+ setLoading(true)
worker.execute {
val directory = File(filesDir, "imported-apks").apply { mkdirs() }
val temporary = File.createTempFile("import-", ".partial", directory)
- try {
+ val result = runCatching {
val digest = MessageDigest.getInstance("SHA-256")
contentResolver.openInputStream(uri).use { input ->
- requireNotNull(input) { "Unable to open the selected document" }
+ requireNotNull(input) { "Unable to open the selected APK" }
FileOutputStream(temporary).use { output ->
val buffer = ByteArray(64 * 1024)
while (true) {
@@ -266,19 +588,19 @@ class FridaBoxActivity : AppCompatActivity() {
val originalHash = digest.digest().joinToString("") { "%02x".format(it) }
val archiveInfo = packageManager.getPackageArchiveInfo(temporary.absolutePath, PackageManager.GET_META_DATA)
?: error("Android could not parse this APK")
- if (!archiveInfo.splitNames.isNullOrEmpty()) error("Split-only APKs are not supported in this MVP")
+ if (!archiveInfo.splitNames.isNullOrEmpty()) error("Split-only APKs are not supported")
val abi = ApkInspector.inspect(temporary)
- if (!abi.supported) error("32-bit-only/native APK rejected: ${abi.description()}")
- val safePackage = archiveInfo.packageName.replace(Regex("[^A-Za-z0-9._-]"), "_")
+ if (!abi.supported) error("Unsupported native ABI: ${abi.description()}")
+ val safePackage = safePackageName(archiveInfo.packageName)
val stored = File(directory, "$safePackage-${originalHash.take(12)}.apk")
- if (stored.exists()) stored.delete()
+ if (stored.exists() && !stored.delete()) error("Unable to replace the imported APK")
if (!temporary.renameTo(stored)) error("Unable to move APK into private storage")
- if (ApkIntegrity.sha256(stored) != originalHash) error("SHA-256 changed while importing")
+ if (ApkIntegrity.sha256(stored) != originalHash) error("APK integrity check failed after import")
stored.setReadable(true, true)
stored.setWritable(false, false)
- val installResult = BlackBoxCore.get().installPackageAsUser(stored, 0)
- if (!installResult.success) error(installResult.msg ?: "Virtual installation failed")
- if (ApkIntegrity.sha256(stored) != originalHash) error("Stored APK was modified during virtual installation")
+ val install = BlackBoxCore.get().installPackageAsUser(stored, 0)
+ if (!install.success) error(install.msg ?: "Virtual installation failed")
+ if (ApkIntegrity.sha256(stored) != originalHash) error("Stored APK changed during installation")
metadata.edit()
.putString("${archiveInfo.packageName}.sha256", originalHash)
.putString("${archiveInfo.packageName}.source", stored.absolutePath)
@@ -286,138 +608,403 @@ class FridaBoxActivity : AppCompatActivity() {
.putString("${archiveInfo.packageName}.version", archiveInfo.versionName)
.putInt("${archiveInfo.packageName}.targetSdk", archiveInfo.applicationInfo?.targetSdkVersion ?: -1)
.apply()
- InstrumentationSettings.setEnabledForPackage(archiveInfo.packageName, true)
- runOnUiThread {
- toast("Imported ${archiveInfo.packageName}; SHA-256 verified")
- showHome()
- }
- } catch (error: Throwable) {
- temporary.delete()
- runOnUiThread { toast("Import rejected: ${error.message}") }
+ InstrumentationSettings.setModeForPackage(archiveInfo.packageName, InstrumentationSettings.MODE_COMPUTER)
+ archiveInfo.packageName
}
- }
- }
-
- private fun installDemoGuest() {
- worker.execute {
- try {
- val directory = File(filesDir, "imported-apks").apply { mkdirs() }
- val output = File(directory, "sample-guest.apk")
- if (output.exists() && !output.delete()) error("Unable to replace the prior demo APK")
- assets.open("demo/sample-guest.apk").use { input ->
- FileOutputStream(output).use { input.copyTo(it) }
- }
- val sha = ApkIntegrity.sha256(output)
- output.setWritable(false, false)
- val result = BlackBoxCore.get().installPackageAsUser(output, 0)
- if (!result.success) error(result.msg ?: "Demo virtual installation failed")
- metadata.edit().putString("${result.packageName}.sha256", sha)
- .putString("${result.packageName}.source", output.absolutePath)
- .putString("${result.packageName}.abi", ApkInspector.inspect(output).description()).apply()
- runOnUiThread { toast("Demo guest installed into virtual space"); showHome() }
- } catch (error: Throwable) {
- runOnUiThread { toast("Demo install failed: ${error.message}") }
+ if (result.isFailure) temporary.delete()
+ runOnUiThread {
+ setLoading(false)
+ result.onSuccess {
+ notify("$it imported and verified")
+ showWorkspace()
+ }.onFailure { notify("APK import failed: ${it.message}") }
}
}
}
private fun showRuntime(packageHint: String? = null) {
- baseScreen("Runtime status")
- val packageName = settings.getString("runtime_package", packageHint) ?: packageHint ?: "No guest bound"
- val state = settings.getString("runtime_state", "idle")
- val basePort = settings.getInt(InstrumentationSettings.KEY_BASE_PORT, 27042)
- val count = settings.getInt(InstrumentationSettings.KEY_SCAN_COUNT, 32)
- val command = "python tools/attach_guest.py --package $packageName --keep-alive"
- content.addView(detail("Guest package", packageName))
- content.addView(detail("Guest process", settings.getString("runtime_process", "unknown") ?: "unknown"))
- content.addView(detail("Virtual process slot", settings.getInt("runtime_vpid", -1).toString()))
- content.addView(detail("Guest source path", settings.getString("runtime_source", "unknown") ?: "unknown"))
- content.addView(detail("Instrumentation enabled", settings.getBoolean("runtime_enabled", false).toString()))
- content.addView(detail("Gadget load status", state ?: "idle"))
- content.addView(detail("Expected port range", "$basePort..${basePort + count - 1}"))
- content.addView(detail("Latest error", settings.getString("runtime_error", "none") ?: "none"))
- content.addView(TextView(this).apply {
- text = "Attach command\n$command"
- setTextIsSelectable(true)
- setPadding(0, dp(12), 0, dp(8))
+ screen = Screen.RUNTIME
+ resetScreen(R.id.nav_runtime, showImport = false)
+ 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 state = settings.getString("runtime_state", "idle") ?: "idle"
+ val mode = settings.getString("runtime_mode", packageName?.let {
+ InstrumentationSettings.getModeForPackage(it)
+ } ?: InstrumentationSettings.MODE_CLEAN) ?: InstrumentationSettings.MODE_CLEAN
+ val stateColor = when (state) {
+ "local_script_active", "computer_attached" -> R.color.fb_success
+ "failed" -> R.color.fb_error
+ "waiting_for_attach", "loading_local_script" -> R.color.fb_warning
+ else -> R.color.fb_text_secondary
+ }
+
+ binding.content.addView(pageHeading(
+ getString(R.string.fb_runtime_title),
+ getString(R.string.fb_runtime_subtitle)
+ ))
+ binding.content.addView(messageCard(
+ runtimeStateLabel(state),
+ packageName ?: "No guest process has reported runtime state yet.",
+ stateColor
+ ))
+
+ if (mode == InstrumentationSettings.MODE_COMPUTER) {
+ val command = "frida -U gadget -l path/to/agent.js"
+ binding.content.addView(infoCard("Computer attach", buildString {
+ append("The guest waits at Gadget until a controller attaches.\n\n")
+ append(command)
+ }, command))
+ } else if (mode == InstrumentationSettings.MODE_LOCAL_SCRIPT) {
+ val scriptName = packageName?.let { metadata.getString("$it.scriptName", null) }
+ val scriptHash = packageName?.let { metadata.getString("$it.scriptSha", null) }
+ binding.content.addView(infoCard(
+ "On-device agent",
+ buildString {
+ append(scriptName ?: "No agent selected")
+ if (scriptHash != null) append("\nSHA-256 ").append(scriptHash)
+ append("\n\nThe private agent is loaded autonomously by Frida Gadget.")
+ }
+ ))
+ } else {
+ binding.content.addView(infoCard("Clean launch", getString(R.string.fb_mode_clean_body)))
+ }
+
+ val details = listOf(
+ "Guest package" to (packageName ?: "Not reported"),
+ "Guest process" to (settings.getString("runtime_process", "—") ?: "—"),
+ "Virtual process slot" to settings.getInt("runtime_vpid", -1).toString(),
+ "Guest source" to (settings.getString("runtime_source", "—") ?: "—"),
+ "Virtual user ID" to settings.getInt("runtime_user_id", -1).toString(),
+ "ClassLoader" to (settings.getString("runtime_class_loader", "Not reported") ?: "Not reported"),
+ "Mode" to modeLongLabel(mode),
+ "Latest error" to (settings.getString("runtime_error", "None") ?: "None")
+ )
+ binding.content.addView(detailsCard("Process registry", details))
+ binding.content.addView(outlineButton(getString(R.string.fb_refresh)) { showRuntime(packageHint) }.apply {
+ layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(50)).apply { topMargin = dp(14) }
})
- content.addView(Button(this).apply {
- text = "Copy attach command"
- setOnClickListener {
- (getSystemService(CLIPBOARD_SERVICE) as ClipboardManager)
- .setPrimaryClip(ClipData.newPlainText("FridaBox attach", command))
- toast("Attach command copied")
- }
- })
- content.addView(Button(this).apply { text = "Refresh"; setOnClickListener { showRuntime(packageHint) } })
}
private fun showSettings() {
- baseScreen("Instrumentation settings")
- val enabled = Switch(this).apply {
- text = "Instrumentation enabled"
+ screen = Screen.SETTINGS
+ resetScreen(R.id.nav_settings, showImport = false)
+ binding.toolbar.title = getString(R.string.fb_settings_title)
+ binding.toolbar.subtitle = getString(R.string.fb_settings_subtitle)
+ binding.content.addView(pageHeading(
+ getString(R.string.fb_settings_title),
+ getString(R.string.fb_settings_subtitle)
+ ))
+
+ val controls = surfaceCard()
+ val body = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(18), dp(18), dp(18), dp(18))
+ }
+ controls.addView(body)
+ val enabled = SwitchMaterial(this).apply {
+ text = getString(R.string.fb_global_instrumentation)
+ setTextColor(color(R.color.fb_text_primary))
isChecked = settings.getBoolean(InstrumentationSettings.KEY_ENABLED, true)
}
- val pause = CheckBox(this).apply {
- text = "Pause guest until attach (required for this MVP)"
- isChecked = true
- isEnabled = false
- }
- val port = numericSetting("Frida base port", settings.getInt(InstrumentationSettings.KEY_BASE_PORT, 27042))
- val count = numericSetting("Port scan count", settings.getInt(InstrumentationSettings.KEY_SCAN_COUNT, 32))
- val logs = Switch(this).apply {
- text = "Show advanced logs"
+ val logs = SwitchMaterial(this).apply {
+ text = getString(R.string.fb_advanced_logs)
+ setTextColor(color(R.color.fb_text_primary))
isChecked = settings.getBoolean(InstrumentationSettings.KEY_ADVANCED_LOGS, false)
}
- content.addView(enabled); content.addView(pause); content.addView(port.first); content.addView(count.first); content.addView(logs)
- content.addView(Button(this).apply {
- text = "Save settings"
- setOnClickListener {
- settings.edit()
- .putBoolean(InstrumentationSettings.KEY_ENABLED, enabled.isChecked)
- .putInt(InstrumentationSettings.KEY_BASE_PORT, InstrumentationPreferenceParser.parsePort(port.second.text.toString(), 27042))
- .putInt(InstrumentationSettings.KEY_SCAN_COUNT, InstrumentationPreferenceParser.parseScanCount(count.second.text.toString(), 32))
- .putBoolean(InstrumentationSettings.KEY_ADVANCED_LOGS, logs.isChecked)
- .apply()
- toast("Settings saved")
- }
- })
- content.addView(TextView(this).apply {
- text = "Limitations: FridaBox is not undetectable. The host UID/SELinux domain, virtual stub process, host classes, ClassLoader topology, synthesized Binder responses, Gadget module/threads/socket, and /proc/self/maps remain observable. Play Integrity and hardware-backed attestation are not virtualized."
- setPadding(0, dp(20), 0, 0)
+ val port = numberInput(getString(R.string.fb_base_port), settings.getInt(InstrumentationSettings.KEY_BASE_PORT, 27042))
+ val count = numberInput(getString(R.string.fb_scan_count), settings.getInt(InstrumentationSettings.KEY_SCAN_COUNT, 32))
+ body.addView(enabled)
+ body.addView(logs)
+ body.addView(port.first)
+ body.addView(count.first)
+ body.addView(primaryButton(getString(R.string.fb_save_settings)) {
+ settings.edit()
+ .putBoolean(InstrumentationSettings.KEY_ENABLED, enabled.isChecked)
+ .putInt(InstrumentationSettings.KEY_BASE_PORT,
+ InstrumentationPreferenceParser.parsePort(port.second.text?.toString().orEmpty(), 27042))
+ .putInt(InstrumentationSettings.KEY_SCAN_COUNT,
+ InstrumentationPreferenceParser.parseScanCount(count.second.text?.toString().orEmpty(), 32))
+ .putBoolean(InstrumentationSettings.KEY_ADVANCED_LOGS, logs.isChecked)
+ .apply()
+ notify("Settings saved")
+ }.apply {
+ layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(50)).apply { topMargin = dp(12) }
})
+ binding.content.addView(controls)
+ binding.content.addView(messageCard(
+ getString(R.string.fb_security_title),
+ getString(R.string.fb_security_body),
+ R.color.fb_warning
+ ))
+ binding.content.addView(messageCard(
+ getString(R.string.fb_about_title),
+ "${getString(R.string.fb_about_body)}\n\nFridaBox ${BuildConfig.VERSION_NAME}",
+ R.color.fb_primary
+ ))
}
- private fun numericSetting(label: String, value: Int): Pair {
- val input = EditText(this).apply {
- setText(value.toString()); inputType = InputType.TYPE_CLASS_NUMBER
+ private fun numberInput(label: String, value: Int): Pair {
+ val input = TextInputEditText(this).apply {
+ setText(value.toString())
+ inputType = InputType.TYPE_CLASS_NUMBER
+ setTextColor(color(R.color.fb_text_primary))
}
- return LinearLayout(this).apply {
- orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL
- addView(TextView(this@FridaBoxActivity).apply { text = label }, LinearLayout.LayoutParams(0, dp(56), 1f))
- addView(input, LinearLayout.LayoutParams(dp(140), dp(56)))
+ return TextInputLayout(this).apply {
+ hint = label
+ boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE
+ boxStrokeColor = color(R.color.fb_outline)
+ defaultHintTextColor = ColorStateList.valueOf(color(R.color.fb_text_secondary))
+ setPadding(0, dp(12), 0, 0)
+ addView(input)
} to input
}
- private fun detail(label: String, value: String) = TextView(this).apply {
- text = "$label: $value"; setTextIsSelectable(true); setPadding(0, dp(5), 0, dp(5))
+ private fun pageHeading(title: String, subtitle: String): View = verticalText(title, subtitle).apply {
+ setPadding(0, 0, 0, dp(18))
}
- private fun sectionTitle(text: String) = TextView(this).apply {
- this.text = text; textSize = 19f; setTypeface(typeface, Typeface.BOLD); setPadding(0, dp(18), 0, dp(4))
- }
-
- private fun actionButton(label: String, action: () -> Unit) = Button(this).apply {
- text = label; setOnClickListener { action() }
- }
-
- private fun displayName(uri: Uri): String {
- contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
- if (it.moveToFirst()) return it.getString(0) ?: "selected.apk"
+ private fun messageCard(title: String, bodyText: String, accent: Int): View {
+ return surfaceCard().apply {
+ strokeColor = color(accent)
+ addView(LinearLayout(this@FridaBoxActivity).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(18), dp(18), dp(18), dp(18))
+ addView(labelText(title, accent, 16f, true))
+ addView(labelText(bodyText, R.color.fb_text_secondary, 13.5f, false).apply {
+ setPadding(0, dp(7), 0, 0)
+ setLineSpacing(0f, 1.1f)
+ })
+ })
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply { bottomMargin = dp(14) }
}
- return uri.lastPathSegment ?: "selected.apk"
}
+ private fun infoCard(title: String, bodyText: String, copyValue: String? = null): View {
+ val card = surfaceCard()
+ val body = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(18), dp(18), dp(18), dp(18))
+ addView(labelText(title, R.color.fb_text_primary, 16f, true))
+ addView(labelText(bodyText, R.color.fb_text_secondary, 13f, false).apply {
+ setPadding(0, dp(8), 0, 0)
+ setTextIsSelectable(true)
+ })
+ if (copyValue != null) addView(outlineButton(getString(R.string.fb_copy_command)) {
+ (getSystemService(CLIPBOARD_SERVICE) as ClipboardManager)
+ .setPrimaryClip(ClipData.newPlainText("FridaBox command", copyValue))
+ notify("Command copied")
+ }.apply {
+ layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(46)).apply { topMargin = dp(12) }
+ })
+ }
+ card.addView(body)
+ card.layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply { bottomMargin = dp(14) }
+ return card
+ }
+
+ private fun detailsCard(title: String, rows: List>): View {
+ val card = surfaceCard()
+ val body = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(18), dp(18), dp(18), dp(18))
+ addView(labelText(title, R.color.fb_text_primary, 16f, true))
+ }
+ rows.forEach { (label, value) ->
+ body.addView(labelText(label.uppercase(Locale.ROOT), R.color.fb_text_secondary, 10.5f, true).apply {
+ setPadding(0, dp(13), 0, 0)
+ })
+ body.addView(labelText(value, R.color.fb_text_primary, 13f, false).apply { setTextIsSelectable(true) })
+ }
+ card.addView(body)
+ card.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
+ return card
+ }
+
+ private fun verticalText(title: String, subtitle: String): LinearLayout {
+ return LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ addView(labelText(title, R.color.fb_text_primary, 19f, true))
+ addView(labelText(subtitle, R.color.fb_text_secondary, 12.5f, false).apply { setPadding(0, dp(3), 0, 0) })
+ }
+ }
+
+ private fun surfaceCard(): MaterialCardView = MaterialCardView(this).apply {
+ radius = resources.getDimension(R.dimen.fb_card_radius)
+ cardElevation = 0f
+ setCardBackgroundColor(color(R.color.fb_surface))
+ strokeColor = color(R.color.fb_outline)
+ strokeWidth = dp(1)
+ }
+
+ private fun labelText(value: String, colorResource: Int, size: Float, bold: Boolean): TextView {
+ return TextView(this).apply {
+ text = value
+ textSize = size
+ setTextColor(color(colorResource))
+ if (bold) setTypeface(typeface, Typeface.BOLD)
+ }
+ }
+
+ private fun badge(value: String, background: Int, foreground: Int): TextView {
+ return TextView(this).apply {
+ text = value
+ textSize = 11f
+ setTypeface(typeface, Typeface.BOLD)
+ gravity = Gravity.CENTER
+ setTextColor(foreground)
+ backgroundTintList = ColorStateList.valueOf(background)
+ this.background = rounded(background, dp(99))
+ setPadding(dp(11), dp(6), dp(11), dp(6))
+ }
+ }
+
+ private fun modeButton(id: Int, label: String): MaterialButton {
+ return MaterialButton(this, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
+ this.id = id
+ text = label
+ textSize = 11.5f
+ isAllCaps = false
+ letterSpacing = 0f
+ insetTop = 0
+ insetBottom = 0
+ setTextColor(checkedColors(color(R.color.fb_black), color(R.color.fb_text_primary)))
+ strokeColor = checkedColors(color(R.color.fb_primary), color(R.color.fb_outline))
+ backgroundTintList = checkedColors(color(R.color.fb_primary), color(R.color.fb_surface))
+ layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)
+ }
+ }
+
+ private fun primaryButton(label: String, action: (View) -> Unit): MaterialButton {
+ return MaterialButton(this).apply {
+ text = label
+ isAllCaps = false
+ letterSpacing = 0f
+ cornerRadius = dp(15)
+ insetTop = 0
+ insetBottom = 0
+ setTextColor(color(R.color.fb_black))
+ backgroundTintList = ColorStateList.valueOf(color(R.color.fb_primary))
+ setOnClickListener(action)
+ }
+ }
+
+ private fun outlineButton(label: String, action: (View) -> Unit): MaterialButton {
+ return MaterialButton(this, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
+ text = label
+ isAllCaps = false
+ letterSpacing = 0f
+ cornerRadius = dp(15)
+ insetTop = 0
+ insetBottom = 0
+ setTextColor(color(R.color.fb_text_primary))
+ strokeColor = ColorStateList.valueOf(color(R.color.fb_outline))
+ backgroundTintList = ColorStateList.valueOf(color(R.color.fb_transparent))
+ setOnClickListener(action)
+ }
+ }
+
+ private fun resetScreen(navId: Int, showImport: Boolean): Int {
+ screenGeneration += 1
+ binding.content.removeAllViews()
+ binding.contentScroll.scrollTo(0, 0)
+ binding.importFab.isVisible = showImport
+ changingNavigation = true
+ binding.bottomNavigation.selectedItemId = navId
+ changingNavigation = false
+ setLoading(false)
+ return screenGeneration
+ }
+
+ private fun setLoading(loading: Boolean) {
+ if (::binding.isInitialized) binding.progress.isVisible = loading
+ }
+
+ private fun checkedColors(checked: Int, unchecked: Int): ColorStateList {
+ return ColorStateList(
+ arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()),
+ intArrayOf(checked, unchecked)
+ )
+ }
+
+ private fun runtimeStateLabel(state: String): String = when (state) {
+ "local_script_active" -> "On-device agent active"
+ "computer_attached" -> "Computer attached"
+ "waiting_for_attach" -> "Waiting for computer"
+ "loading_local_script" -> "Loading on-device agent"
+ "failed" -> "Instrumentation failed"
+ "disabled" -> "Clean runtime"
+ else -> "Runtime idle"
+ }
+
+ private fun modeShortLabel(mode: String): String = when (mode) {
+ InstrumentationSettings.MODE_LOCAL_SCRIPT -> getString(R.string.fb_mode_local)
+ InstrumentationSettings.MODE_CLEAN -> getString(R.string.fb_mode_clean)
+ else -> getString(R.string.fb_mode_computer)
+ }
+
+ private fun modeLongLabel(mode: String): String = when (mode) {
+ InstrumentationSettings.MODE_LOCAL_SCRIPT -> getString(R.string.fb_mode_local_title)
+ InstrumentationSettings.MODE_CLEAN -> getString(R.string.fb_mode_clean_title)
+ else -> getString(R.string.fb_mode_computer_title)
+ }
+
+ private fun modeColor(mode: String, background: Boolean): Int {
+ val resource = when (mode) {
+ InstrumentationSettings.MODE_LOCAL_SCRIPT -> if (background) R.color.fb_surface_tint else R.color.fb_success
+ InstrumentationSettings.MODE_CLEAN -> if (background) R.color.fb_surface_tint else R.color.fb_text_secondary
+ else -> if (background) R.color.fb_surface_tint else R.color.fb_warning
+ }
+ return color(resource)
+ }
+
+ private fun displayName(uri: Uri, fallback: String): String {
+ contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
+ if (it.moveToFirst()) return it.getString(0) ?: fallback
+ }
+ return uri.lastPathSegment ?: fallback
+ }
+
+ private fun agentDirectory(packageName: String): File =
+ File(File(filesDir, "fridabox-agents"), safePackageName(packageName))
+
+ private fun deleteAgentDirectory(packageName: String) {
+ val root = File(filesDir, "fridabox-agents").canonicalFile
+ val directory = agentDirectory(packageName).canonicalFile
+ if (!directory.path.startsWith(root.path + File.separator)) return
+ directory.listFiles()?.forEach { child -> if (child.isFile) child.delete() }
+ directory.delete()
+ }
+
+ private fun safePackageName(packageName: String): String =
+ packageName.replace(Regex("[^A-Za-z0-9._-]"), "_")
+
+ private fun rounded(fill: Int, radius: Int) = GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ setColor(fill)
+ cornerRadius = radius.toFloat()
+ }
+
+ private fun color(resource: Int): Int = ContextCompat.getColor(this, resource)
+ private fun space(width: Int, height: Int) = Space(this).apply {
+ layoutParams = LinearLayout.LayoutParams(width, height)
+ }
+ private fun notify(message: String) = Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show()
private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_LONG).show()
private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
+
+ companion object {
+ private const val MAX_AGENT_SIZE = 16L * 1024L * 1024L
+ private const val MENU_DETAILS = 1
+ private const val MENU_RUNTIME = 2
+ private const val MENU_CLEAR = 3
+ private const val MENU_REMOVE = 4
+ }
}
diff --git a/app/src/main/res/drawable/bg_fridabox_hero.xml b/app/src/main/res/drawable/bg_fridabox_hero.xml
new file mode 100644
index 0000000..1775d01
--- /dev/null
+++ b/app/src/main/res/drawable/bg_fridabox_hero.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fb_add.xml b/app/src/main/res/drawable/ic_fb_add.xml
new file mode 100644
index 0000000..4da4801
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fb_add.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fb_runtime.xml b/app/src/main/res/drawable/ic_fb_runtime.xml
new file mode 100644
index 0000000..106e440
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fb_runtime.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fb_settings.xml b/app/src/main/res/drawable/ic_fb_settings.xml
new file mode 100644
index 0000000..6f5dd16
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fb_settings.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fb_workspace.xml b/app/src/main/res/drawable/ic_fb_workspace.xml
new file mode 100644
index 0000000..93292db
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fb_workspace.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fridabox_app.xml b/app/src/main/res/drawable/ic_fridabox_app.xml
new file mode 100644
index 0000000..d26f68f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fridabox_app.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fridabox_mark.xml b/app/src/main/res/drawable/ic_fridabox_mark.xml
new file mode 100644
index 0000000..774481c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fridabox_mark.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_fridabox.xml b/app/src/main/res/layout/activity_fridabox.xml
new file mode 100644
index 0000000..af580db
--- /dev/null
+++ b/app/src/main/res/layout/activity_fridabox.xml
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/fridabox_navigation.xml b/app/src/main/res/menu/fridabox_navigation.xml
new file mode 100644
index 0000000..905a16e
--- /dev/null
+++ b/app/src/main/res/menu/fridabox_navigation.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/app/src/main/res/values-sw600dp/fridabox_dimens.xml b/app/src/main/res/values-sw600dp/fridabox_dimens.xml
new file mode 100644
index 0000000..95b5908
--- /dev/null
+++ b/app/src/main/res/values-sw600dp/fridabox_dimens.xml
@@ -0,0 +1,8 @@
+
+
+ 32dp
+ 28dp
+ 120dp
+ 24dp
+ 920dp
+
diff --git a/app/src/main/res/values/fridabox_colors.xml b/app/src/main/res/values/fridabox_colors.xml
new file mode 100644
index 0000000..bad663d
--- /dev/null
+++ b/app/src/main/res/values/fridabox_colors.xml
@@ -0,0 +1,18 @@
+
+
+ #080C14
+ #111827
+ #182235
+ #1D2940
+ #64E8E8
+ #1AAFB5
+ #9B87F5
+ #F8FAFC
+ #9CAEC5
+ #2B3A52
+ #42D39A
+ #F5BF5B
+ #FB7185
+ #05070B
+ #00000000
+
diff --git a/app/src/main/res/values/fridabox_dimens.xml b/app/src/main/res/values/fridabox_dimens.xml
new file mode 100644
index 0000000..9c131ec
--- /dev/null
+++ b/app/src/main/res/values/fridabox_dimens.xml
@@ -0,0 +1,8 @@
+
+
+ 18dp
+ 20dp
+ 112dp
+ 22dp
+ 840dp
+
diff --git a/app/src/main/res/values/fridabox_strings.xml b/app/src/main/res/values/fridabox_strings.xml
new file mode 100644
index 0000000..7b450bd
--- /dev/null
+++ b/app/src/main/res/values/fridabox_strings.xml
@@ -0,0 +1,59 @@
+
+
+ FridaBox
+ Mobile instrumentation workspace
+ Workspace
+ Runtime
+ Settings
+ Import APK
+ PRIVATE · NON-ROOT · ARM64
+ Instrument apps. Keep control.
+ Run trusted JavaScript agents directly on the device, connect from a computer, or launch a clean guest—per app.
+ Guest workspace
+ Each guest keeps its own launch mode and agent.
+ Your workspace is empty
+ Import one ARM64 base APK to create a private FridaBox guest.
+ On-device
+ Computer
+ Clean
+ On-device agent
+ The selected JavaScript runs automatically before app startup. No cable or controller is required.
+ Computer attach
+ Startup pauses at Frida Gadget until you attach from a computer.
+ Clean launch
+ Starts the guest without loading Frida Gadget.
+ Select JavaScript
+ Replace agent
+ Launch
+ Manage
+ No JavaScript agent selected
+ Agent ready
+ Runtime
+ Latest guest process and instrumentation state
+ Settings
+ Connection defaults and product information
+ Save settings
+ Allow instrumentation
+ Show advanced runtime details
+ Gadget base port
+ Port discovery range
+ Run only agents you trust
+ An on-device agent executes inside the virtual guest process and has the same access as that process. FridaBox stores a private byte-for-byte copy of the selected file.
+ Independent by design
+ FridaBox combines a private Android virtualization layer with Frida Gadget. It does not modify imported APKs and it does not require root.
+ Copy command
+ Refresh
+ Choose a trusted JavaScript agent?
+ The file will be copied into FridaBox private storage and executed automatically whenever this guest starts in On-device mode.
+ Choose file
+ Cancel
+ Ready to attach from a computer?
+ The guest will pause before its Application starts. Connect with Frida to resume it.
+ Clear guest data
+ Runtime details
+ Remove guest
+ Remove this guest?
+ The virtual app, its virtual data, and its saved on-device agent will be removed from FridaBox. The Android PackageManager installation is not changed.
+ Remove
+ App details
+
diff --git a/app/src/main/res/values/fridabox_styles.xml b/app/src/main/res/values/fridabox_styles.xml
new file mode 100644
index 0000000..0c19a5d
--- /dev/null
+++ b/app/src/main/res/values/fridabox_styles.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
diff --git a/docs/BUILDING.md b/docs/BUILDING.md
index 8581d93..cc97388 100644
--- a/docs/BUILDING.md
+++ b/docs/BUILDING.md
@@ -52,3 +52,33 @@ Build and test:
The debug host build depends on the sample build and copies its byte-identical
APK into generated debug assets. Generated sample APKs are not source-controlled.
The final host output is under `app/build/outputs/apk/debug/` and is ARM64-only.
+
+## Production release
+
+The release variant enables R8 optimization, code shrinking, and resource
+shrinking. It never falls back to the Android debug signing key.
+
+Build an unsigned release artifact for later signing:
+
+```powershell
+$env:FRIDABOX_NDK_PROJECT_DIR='D:\FridaBoxBuild\Bcore'
+.\gradlew.bat :app:assembleRelease
+```
+
+For a signed production build, provide all four variables before running the
+same task:
+
+```powershell
+$env:FRIDABOX_RELEASE_STORE_FILE='D:\secure\fridabox-release.jks'
+$env:FRIDABOX_RELEASE_STORE_PASSWORD=''
+$env:FRIDABOX_RELEASE_KEY_ALIAS='fridabox'
+$env:FRIDABOX_RELEASE_KEY_PASSWORD=''
+.\gradlew.bat :app:assembleRelease
+```
+
+If only some signing variables are present, configuration fails instead of
+producing an ambiguously signed artifact. Keep the keystore and credentials
+outside the repository and CI logs.
+
+The ARM64 release output is written to
+`app/build/outputs/apk/release/FridaBox_4.0.0_arm64-v8a-release.apk`.
diff --git a/docs/FRIDA_CONNECTION.md b/docs/FRIDA_CONNECTION.md
index fe70274..3d2cfba 100644
--- a/docs/FRIDA_CONNECTION.md
+++ b/docs/FRIDA_CONNECTION.md
@@ -33,6 +33,18 @@ bounded ten seconds when the ClassLoader is temporarily unavailable.
The controller prints the registry JSON, selected ClassLoader, and native-module
enumeration before loading the user script.
+## On-device mode
+
+Computer-side forwarding and controller tools are not used in On-device mode.
+FridaBox creates a private Gadget copy with an adjacent Script-interaction
+configuration and a relative `agent.js` path. Gadget loads that script before
+returning to guest Application creation. The selected script is stored per
+package and reused automatically until it is replaced or the guest is removed.
+
+Use Computer mode when the script needs an interactive host, RPC calls, or
+observable `send()` messages. Use On-device mode for autonomous hooks and
+in-process behavior.
+
Use `tools/forward_frida_ports.py` when only port forwarding is needed. A client
major-version mismatch prints the exact `pip install frida==17.16.0` repair
command.
diff --git a/docs/TEST_RESULTS.md b/docs/TEST_RESULTS.md
index aebe89b..40e119a 100644
--- a/docs/TEST_RESULTS.md
+++ b/docs/TEST_RESULTS.md
@@ -68,3 +68,54 @@ Runtime validation passed on a Samsung SM-S928B running ARM64 Android 16/API 36:
The command and log transcript, including two device-discovered fixes, is in
`docs/device-validation.log`.
+
+## Commercial workspace and per-app mode validation
+
+Validation date: 2026-07-20
+
+The redesigned FridaBox launcher and all three per-app modes were validated on
+the same Samsung SM-S928B, ARM64 Android 16/API 36 device with the imported
+`com.paeezanstudio.pesarkhande` 3.3.7 guest.
+
+- The independent FridaBox launcher, icon, dark product theme, responsive
+ workspace cards, bottom navigation, import action, and selected-mode states
+ rendered correctly at 1080 x 2340.
+- `pesarkhande-agent.js` (198,960 bytes, SHA-256
+ `41dd04f7a6a4b8de47fcd94ee5646f43effd8f73b36eda64d46a65f4f304fa49`)
+ was selected through Android's document picker and copied without modification.
+- On-device mode loaded the private Gadget and Script configuration without a
+ controller, then returned to `beforeCreateApplication`; the Unity game reached
+ its interactive home screen.
+- Runtime reported `local_script_active`, package
+ `com.paeezanstudio.pesarkhande`, virtual user ID 0, virtual process slot 1,
+ the private source APK, and `dalvik.system.PathClassLoader`.
+- Computer mode paused before `beforeCreateApplication`. Direct
+ `frida -U gadget` attachment resumed the guest and enumerated 416 native
+ modules; the first five were `app_process64`, `linker64`,
+ `libandroid_runtime.so`, `libbinder.so`, and `libcutils.so`.
+- Clean mode recycled the main guest PID from 26464 to 27819, emitted
+ `Instrumentation disabled for this guest process`, opened no Gadget listener,
+ and launched the game normally.
+- A stale cross-process SharedPreferences cache initially made Runtime display
+ `Waiting for computer` for a successful on-device launch. Multi-process reload
+ semantics fixed the display; the persisted state was already correct.
+- The private JavaScript file is mode 0400 and the private Gadget executable is
+ mode 0555 at launch. Android 16 no longer reports the writable-executable
+ warning for the FridaBox Gadget copy.
+
+Final automated builds and tests passed:
+
+```powershell
+.\gradlew.bat :app:assembleDebug :app:assembleRelease :Bcore:testDebugUnitTest :app:testDebugUnitTest
+```
+
+Artifacts:
+
+- debug: 21,049,981 bytes, SHA-256
+ `80e70b33fca741e4f805aa233cdfaf5bc6fe2030e93c8fd825611eb5c407c917`;
+- release: 13,266,764 bytes, SHA-256
+ `caa2218194fcbe91c10d0d29a74b7401aaed53f340b8a0d6668321ddae48ddfb`.
+
+The release artifact was intentionally unsigned because no production keystore
+was supplied. `apksigner` confirmed the debug APK verifies and the release APK
+does not contain a debug signature.
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 04fa267..5d67c99 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -1,27 +1,53 @@
# Usage
-1. Install and open the FridaBox host on an ARM64 Android 12–16 research device.
-2. Tap **Import APK** and select one base `.apk` through the system document
- picker. `.apks`, `.xapk`, `.apkm`, split-only packages, and 32-bit-only native
- APKs are rejected.
-3. Review package, version, SHA-256, private source path, and ABI status.
-4. Tap **Launch instrumented**. Startup intentionally pauses before the guest
- `Application` is created.
-5. Run the attach command shown on **Runtime status**. Attach and load hooks.
-6. Use **Launch without instrumentation** for a clean virtual process where the
- Gadget is not loaded. FridaBox stops the package before switching modes.
+FridaBox keeps every imported application inside its private virtual workspace.
+Importing an APK does not install that package into Android's real PackageManager.
-Debug builds expose **Install demo guest**. The action installs the generated
-`com.qm4rs.fridabox.sample` APK only into BlackBox. Then run:
+## Import an application
-```text
-npm ci
-python tools/build_frida_agents.py
-python tools/attach_guest.py --package com.qm4rs.fridabox.sample --script scripts/sample-hook.js --keep-alive
-```
+1. Open **Workspace** and tap **Import APK**.
+2. Select one ARM64 base `.apk` through Android's document picker.
+3. FridaBox validates the APK, records its SHA-256, and creates a private guest.
-After startup resumes, press the sample button. The visible result should be
-`1337`, demonstrating that the guest ClassLoader was selected.
+Split-only packages (`.apks`, `.xapk`, and `.apkm`) and unsupported native ABIs
+are rejected.
-**Clear virtual app data** and **Remove from virtual space** affect only the
-BlackBox virtual environment. They do not invoke Android's real package manager.
+## Choose a launch mode
+
+Every guest remembers one of three independent modes:
+
+- **On-device**: select a trusted `.js` file once. FridaBox stores a byte-for-byte
+ private copy, records its SHA-256, and loads it through Frida Gadget's Script
+ interaction before the guest Application starts. No cable, computer, Frida CLI,
+ port forwarding, or controller process is required on later launches.
+- **Computer**: starts the loopback-only Gadget listener and pauses before the
+ guest Application. Attach through the normal Frida workflow, for example:
+
+ ```text
+ frida -U gadget -l path/to/agent.js
+ ```
+
+- **Clean**: recycles the virtual process and launches without loading Frida
+ Gadget. The selected on-device agent is retained for future use.
+
+Switching modes always stops the previous guest process before the next launch.
+
+## On-device agents
+
+Select **On-device**, tap **Select JavaScript**, review the trust warning, and
+choose a `.js` file up to 16 MiB. The original file is not modified. The private
+copy and the private Gadget executable are made read-only before execution.
+
+An autonomous script has no computer-side message handler. Calls such as `send()`
+may be intentionally unobserved, while hooks, replacements, Java calls, native
+interceptors, and in-app overlays continue to run in the guest process.
+
+## Runtime and management
+
+The **Runtime** tab reports the latest package, process, virtual user ID, virtual
+process slot, source APK, ClassLoader, selected mode, state, and latest error.
+Use **Manage** on an app card for app details, runtime details, virtual data
+clearing, or removal from the private workspace.
+
+Only run JavaScript agents you trust. An on-device agent executes with the same
+access as the virtual guest process.
diff --git a/docs/device-validation.log b/docs/device-validation.log
index 4c9efae..ec982da 100644
--- a/docs/device-validation.log
+++ b/docs/device-validation.log
@@ -161,3 +161,107 @@ Java agents with frida-compile 19.0.5. The early registry probe also uses
Java.performNow(), avoiding a deadlock with Gadget's on_load=wait main thread.
Final result: all required sample runtime checks passed on ARM64 Android 16.
+
+-------------------------------------------------------------------------------
+2026-07-20 - Commercial UI and per-app execution modes
+-------------------------------------------------------------------------------
+
+Device reconfirmation:
+
+> adb shell getprop ro.product.cpu.abi
+arm64-v8a
+
+> adb shell getprop ro.build.version.release
+16
+
+> adb shell getprop ro.build.version.sdk
+36
+
+> adb install -r -t app\build\outputs\apk\debug\FridaBox_4.0.0_arm64-v8a-debug.apk
+Performing Streamed Install
+Success
+
+The branded launcher was inspected by screenshot and UIAutomator at 1080 x
+2340. FridaBox opened directly into Workspace; the selected mode had a distinct
+state, the header was not clipped, and Import APK did not overlap guest actions.
+
+On-device agent import:
+
+> adb push D:\Reverse\Game-Hacking\pesarkhande\release\pesarkhande-agent.js /sdcard/Download/pesarkhande-agent.js
+1 file pushed, 198960 bytes
+
+> Get-FileHash -Algorithm SHA256 D:\Reverse\Game-Hacking\pesarkhande\release\pesarkhande-agent.js
+41dd04f7a6a4b8de47fcd94ee5646f43effd8f73b36eda64d46a65f4f304fa49
+
+The UI selected On-device -> Select JavaScript -> pesarkhande-agent.js and
+displayed the same SHA-256 prefix. The private configuration was:
+
+{
+ "interaction": {
+ "type": "script",
+ "path": "agent.js",
+ "on_change": "reload",
+ "parameters": { "package": "com.paeezanstudio.pesarkhande" }
+ },
+ "runtime": "qjs",
+ "teardown": "minimal"
+}
+
+Autonomous launch evidence (no Frida client was attached):
+
+07-20 02:46:59.510 760 760 I FridaBox.Gadget: Loading on-device Frida agent for com.paeezanstudio.pesarkhande
+07-20 02:46:59.562 760 760 D nativeloader: Load /data/user/0/com.qm4rs.fridabox/files/fridabox-agents/com.paeezanstudio.pesarkhande/libfridabox-agent.so ...: ok
+07-20 02:46:59.569 760 760 I FridaBox.Gadget: Frida Gadget loaded
+07-20 02:46:59.570 760 760 D BlackBoxLoader: beforeCreateApplication: pkg com.paeezanstudio.pesarkhande, processName com.paeezanstudio.pesarkhande,userID:0
+
+The top resumed activity was ProxyActivity$P0 and the Unity guest reached its
+interactive home screen. Private file permissions after launch:
+
+- agent.js: `-r--------`, 198960 bytes
+- libfridabox-agent.config.so: `-rw-------`, 210 bytes
+- libfridabox-agent.so: `-r-xr-xr-x`, 25212656 bytes
+
+Runtime snapshot:
+
+- runtime_state: local_script_active
+- runtime_package: com.paeezanstudio.pesarkhande
+- runtime_process: com.paeezanstudio.pesarkhande:Metrica
+- runtime_user_id: 0
+- runtime_vpid: 1
+- runtime_source: /data/user/0/com.qm4rs.fridabox/blackbox/data/app/com.paeezanstudio.pesarkhande/base.apk
+- runtime_class_loader: dalvik.system.PathClassLoader@6c0723b
+- runtime_error: none
+
+Computer mode regression:
+
+07-20 02:30:02.989 26464 26464 I FridaBox.Gadget: Loading Frida Gadget listener for com.paeezanstudio.pesarkhande
+07-20 02:30:03.026 26464 26487 I Frida: Listening on 127.0.0.1 TCP port 27042
+
+No `beforeCreateApplication` line existed before attachment.
+
+> frida -U gadget -q -e "send({type:'fridabox-probe',pid:Process.id,moduleCount:Process.enumerateModules().length,firstModules:Process.enumerateModules().slice(0,5).map(function(m){return m.name;})})"
+message: {'type': 'send', 'payload': {'type': 'fridabox-probe', 'pid': 26464, 'moduleCount': 416, 'firstModules': ['app_process64', 'linker64', 'libandroid_runtime.so', 'libbinder.so', 'libcutils.so']}} data: None
+
+07-20 02:31:04.593 26464 26464 I FridaBox.Gadget: Frida Gadget loaded
+07-20 02:31:04.594 26464 26464 D BlackBoxLoader: beforeCreateApplication: pkg com.paeezanstudio.pesarkhande, processName com.paeezanstudio.pesarkhande,userID:0
+
+Clean mode regression after virtual-process recycling:
+
+- previous main PID: 26464
+- clean main PID: 27819
+
+07-20 02:33:27.289 27819 27819 I FridaBox.Gadget: Instrumentation disabled for this guest process
+07-20 02:33:27.290 27819 27819 D BlackBoxLoader: beforeCreateApplication: pkg com.paeezanstudio.pesarkhande, processName com.paeezanstudio.pesarkhande,userID:0
+
+There was no Gadget listener or Gadget load in the clean process. The saved
+agent remained available, and the final device state was restored to On-device.
+
+Final build:
+
+> .\gradlew.bat :app:assembleDebug :app:assembleRelease :Bcore:testDebugUnitTest :app:testDebugUnitTest
+BUILD SUCCESSFUL
+
+- debug APK: 21049981 bytes, SHA-256 80e70b33fca741e4f805aa233cdfaf5bc6fe2030e93c8fd825611eb5c407c917
+- release APK: 13266764 bytes, SHA-256 caa2218194fcbe91c10d0d29a74b7401aaed53f340b8a0d6668321ddae48ddfb
+- debug signature verification: passed
+- release signature verification: intentionally unsigned; no debug key fallback