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