diff --git a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/DownloadedGadgetRuntime.java b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/DownloadedGadgetRuntime.java
new file mode 100644
index 0000000..a5010b7
--- /dev/null
+++ b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/DownloadedGadgetRuntime.java
@@ -0,0 +1,169 @@
+package top.niunaijun.blackbox.instrumentation;
+
+import android.content.Context;
+import android.os.Build;
+
+import java.io.ByteArrayOutputStream;
+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;
+
+/** Resolves a selected, downloaded Gadget and prepares per-guest runtime copies. */
+final class DownloadedGadgetRuntime {
+ private static final String DOWNLOAD_ROOT = "fridabox-gadgets";
+ private static final String RUNTIME_ROOT = "fridabox-gadget-runtimes";
+ private static final String RUNTIME_NAME = "libpayload.so";
+ private static final String CONFIG_NAME = "libpayload.config.so";
+ private static final String SOURCE_NAME = "payload.source";
+
+ private DownloadedGadgetRuntime() {
+ }
+
+ static File prepareListener(String packageName) throws IOException {
+ Context context = BlackBoxCore.getContext();
+ File source = requireSelected(context);
+ File directory = new File(context.getFilesDir(),
+ RUNTIME_ROOT + File.separator + safeName(packageName));
+ if (!directory.isDirectory() && !directory.mkdirs()) {
+ throw new IOException("Unable to create the Gadget runtime directory");
+ }
+ File runtime = new File(directory, RUNTIME_NAME);
+ syncSelected(source, runtime);
+ writeUtf8Atomically(new File(directory, CONFIG_NAME), listenerConfig());
+ return runtime;
+ }
+
+ static File requireSelected(Context context) throws IOException {
+ String selectedPath = InstrumentationSettings.getSelectedGadgetPath();
+ String selectedAbi = InstrumentationSettings.getSelectedGadgetAbi();
+ if (selectedPath == null || selectedPath.trim().isEmpty()) {
+ throw new IOException("No downloaded Frida Gadget is selected");
+ }
+ File root = new File(context.getFilesDir(), DOWNLOAD_ROOT).getCanonicalFile();
+ File selected = new File(selectedPath).getCanonicalFile();
+ if (!isInside(root, selected) || !selected.isFile()) {
+ throw new IOException("The selected Frida Gadget is missing or outside private storage");
+ }
+ if (!supportsAbi(selectedAbi)) {
+ throw new IOException("The selected Frida Gadget ABI is not supported by this device");
+ }
+ validateElf(selected, selectedAbi);
+ return selected;
+ }
+
+ static void syncSelected(File source, File destination) throws IOException {
+ String identity = source.getCanonicalPath() + "\n"
+ + String.valueOf(InstrumentationSettings.getSelectedGadgetSha256()) + "\n";
+ File marker = new File(destination.getParentFile(), SOURCE_NAME);
+ if (!destination.isFile() || destination.length() != source.length()
+ || !identity.equals(readUtf8(marker))) {
+ copyAtomically(source, destination);
+ writeUtf8Atomically(marker, identity);
+ }
+ if (!destination.setReadable(true, false)
+ || !destination.setExecutable(true, false)
+ || !destination.setWritable(false, false)) {
+ throw new IOException("Unable to secure the private Frida Gadget runtime");
+ }
+ }
+
+ private static String listenerConfig() {
+ return "{\n" +
+ " \"interaction\": {\n" +
+ " \"type\": \"listen\",\n" +
+ " \"address\": \"127.0.0.1\",\n" +
+ " \"port\": " + InstrumentationSettings.getBasePort() + ",\n" +
+ " \"on_port_conflict\": \"pick-next\",\n" +
+ " \"on_load\": \"wait\"\n" +
+ " },\n" +
+ " \"runtime\": \"qjs\",\n" +
+ " \"teardown\": \"minimal\"\n" +
+ "}\n";
+ }
+
+ private static void validateElf(File file, String abi) throws IOException {
+ byte[] header = new byte[20];
+ try (FileInputStream input = new FileInputStream(file)) {
+ if (input.read(header) != header.length) throw new IOException("Downloaded Gadget is truncated");
+ }
+ if ((header[0] & 0xff) != 0x7f || header[1] != 'E' || header[2] != 'L' || header[3] != 'F') {
+ throw new IOException("Downloaded Gadget is not an ELF library");
+ }
+ int machine = (header[18] & 0xff) | ((header[19] & 0xff) << 8);
+ int expected = expectedMachine(abi);
+ if (expected < 0 || machine != expected) {
+ throw new IOException("Downloaded Gadget ELF architecture does not match " + abi);
+ }
+ }
+
+ private static int expectedMachine(String abi) {
+ if ("arm64-v8a".equals(abi)) return 183;
+ if ("armeabi-v7a".equals(abi)) return 40;
+ if ("x86".equals(abi)) return 3;
+ if ("x86_64".equals(abi)) return 62;
+ return -1;
+ }
+
+ private static boolean supportsAbi(String abi) {
+ if (abi == null) return false;
+ for (String supported : Build.SUPPORTED_ABIS) {
+ if (abi.equals(supported)) return true;
+ }
+ return false;
+ }
+
+ private static boolean isInside(File root, File child) {
+ return child.getAbsolutePath().startsWith(root.getAbsolutePath() + File.separator);
+ }
+
+ private static String safeName(String value) {
+ return value == null ? "unknown" : value.replaceAll("[^A-Za-z0-9._-]", "_");
+ }
+
+ 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 String readUtf8(File file) {
+ if (!file.isFile() || file.length() > 4096) return null;
+ try (FileInputStream input = new FileInputStream(file);
+ ByteArrayOutputStream output = new ByteArrayOutputStream((int) file.length())) {
+ byte[] buffer = new byte[512];
+ int count;
+ while ((count = input.read(buffer)) >= 0) output.write(buffer, 0, count);
+ return output.toString(StandardCharsets.UTF_8.name());
+ } catch (IOException ignored) {
+ return null;
+ }
+ }
+
+ static void writeUtf8Atomically(File destination, String value) throws IOException {
+ byte[] expected = value.getBytes(StandardCharsets.UTF_8);
+ 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/main/java/top/niunaijun/blackbox/instrumentation/FridaGadgetLoader.java b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/FridaGadgetLoader.java
index 4c3d3aa..fcc30a4 100644
--- a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/FridaGadgetLoader.java
+++ b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/FridaGadgetLoader.java
@@ -52,7 +52,8 @@ public final class FridaGadgetLoader {
System.load(runtime.getAbsolutePath());
} else {
Log.i(TAG, "Loading Frida Gadget listener for " + GuestRuntimeRegistry.getGuestProcessName());
- System.loadLibrary("frida-gadget");
+ File runtime = DownloadedGadgetRuntime.prepareListener(packageName);
+ System.load(runtime.getAbsolutePath());
}
loaded = true;
InstrumentationStatusStore.recordLoaded();
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 19f9f57..d080e0f 100644
--- a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/InstrumentationSettings.java
+++ b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/InstrumentationSettings.java
@@ -12,6 +12,11 @@ public final class InstrumentationSettings {
public static final String KEY_BASE_PORT = "frida_base_port";
public static final String KEY_SCAN_COUNT = "frida_port_scan_count";
public static final String KEY_ADVANCED_LOGS = "show_advanced_logs";
+ public static final String KEY_GADGET_PATH = "selected_gadget_path";
+ public static final String KEY_GADGET_SOURCE = "selected_gadget_source";
+ public static final String KEY_GADGET_VERSION = "selected_gadget_version";
+ public static final String KEY_GADGET_ABI = "selected_gadget_abi";
+ public static final String KEY_GADGET_SHA256 = "selected_gadget_sha256";
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_";
@@ -97,6 +102,54 @@ public final class InstrumentationSettings {
return preferences().getBoolean(KEY_ADVANCED_LOGS, false);
}
+ public static String getSelectedGadgetPath() {
+ return preferences().getString(KEY_GADGET_PATH, null);
+ }
+
+ public static String getSelectedGadgetSource() {
+ return preferences().getString(KEY_GADGET_SOURCE, null);
+ }
+
+ public static String getSelectedGadgetVersion() {
+ return preferences().getString(KEY_GADGET_VERSION, null);
+ }
+
+ public static String getSelectedGadgetAbi() {
+ return preferences().getString(KEY_GADGET_ABI, null);
+ }
+
+ public static String getSelectedGadgetSha256() {
+ return preferences().getString(KEY_GADGET_SHA256, null);
+ }
+
+ public static boolean setSelectedGadget(
+ String path, String source, String version, String abi, String sha256) {
+ if (path == null || path.trim().isEmpty()
+ || source == null || source.trim().isEmpty()
+ || version == null || version.trim().isEmpty()
+ || abi == null || abi.trim().isEmpty()
+ || sha256 == null || sha256.trim().isEmpty()) {
+ return false;
+ }
+ return preferences().edit()
+ .putString(KEY_GADGET_PATH, path)
+ .putString(KEY_GADGET_SOURCE, source)
+ .putString(KEY_GADGET_VERSION, version)
+ .putString(KEY_GADGET_ABI, abi)
+ .putString(KEY_GADGET_SHA256, sha256)
+ .commit();
+ }
+
+ public static boolean clearSelectedGadget() {
+ return preferences().edit()
+ .remove(KEY_GADGET_PATH)
+ .remove(KEY_GADGET_SOURCE)
+ .remove(KEY_GADGET_VERSION)
+ .remove(KEY_GADGET_ABI)
+ .remove(KEY_GADGET_SHA256)
+ .commit();
+ }
+
static int clamp(int value, int minimum, int maximum, int fallback) {
return value < minimum || value > maximum ? fallback : value;
}
diff --git a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntime.java b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntime.java
index 68d7224..5e23222 100644
--- a/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntime.java
+++ b/Bcore/src/main/java/top/niunaijun/blackbox/instrumentation/LocalScriptGadgetRuntime.java
@@ -46,18 +46,10 @@ final class LocalScriptGadgetRuntime {
throw new IOException("Unable to secure the instrumented JavaScript agent");
}
- File source = new File(context.getApplicationInfo().nativeLibraryDir, "libfrida-gadget.so");
- if (!source.isFile()) throw new IOException("Packaged Frida Gadget is missing");
+ File source = DownloadedGadgetRuntime.requireSelected(context);
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");
- }
+ DownloadedGadgetRuntime.syncSelected(source, runtime);
File config = new File(directory, CONFIG_NAME);
writeUtf8Atomically(config, buildConfig(packageName, instrumentedAgent.getAbsolutePath()));
diff --git a/README.md b/README.md
index 763156e..ae505f4 100644
--- a/README.md
+++ b/README.md
@@ -182,12 +182,12 @@ in On-device, Computer, and Clean modes. Evidence and exact commands are in
- JDK 21;
- Android SDK 35 and NDK 29.0.14206865;
- Python 3.10+;
-- Node.js 20+ and npm when rebuilding the bundled Frida 17 agents.
+- Node.js 20+ and npm for the pinned Frida agent toolchain and official Tabler
+ Icons source package.
### Build
```text
-python tools/fetch_frida_gadget.py
npm ci
python tools/build_frida_agents.py
./gradlew :app:assembleDebug :Bcore:testDebugUnitTest :app:testDebugUnitTest :app:check
@@ -200,10 +200,12 @@ documented in [docs/BUILDING.md](docs/BUILDING.md). The output is written under
### Use
1. Install and open FridaBox.
-2. Tap **Import APK** and select an ARM64 base APK.
-3. Choose **On-device**, **Computer**, or **Clean** on that guest's card.
-4. Select an agent for On-device mode, or attach normally in Computer mode.
-5. Inspect live state in the **Runtime** tab.
+2. Open **Gadgets** and download a compatible Official Frida version.
+3. Select one downloaded Gadget as active.
+4. Tap the import icon and choose an installed app or an ARM64 base APK file.
+5. Tap the guest icon and choose **On-device**, **Computer**, or **Clean** in the launch panel.
+6. Select an agent for On-device mode, or attach normally in Computer mode.
+7. Inspect live state in the **Runtime** tab.
See [docs/USAGE.md](docs/USAGE.md) and
[docs/FRIDA_CONNECTION.md](docs/FRIDA_CONNECTION.md) for the complete workflow.
@@ -246,8 +248,9 @@ docs/ Build, usage, limitations and device evidence
## Scope and limitations
-FridaBox currently targets single-file ARM64 APKs. Split APK sets, privileged or
-system applications, full Google Play Services, Play Integrity, hardware-backed
+FridaBox targets ARM64 APKs and can clone the active base/split set of a
+launchable installed app. Standalone split archives, privileged system
+applications, full Google Play Services, Play Integrity, hardware-backed
attestation, and perfect anti-instrumentation resistance are outside the current
scope. Vendor behavior and hidden Android APIs can still affect compatibility.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 2893e82..b35be7e 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -42,6 +42,33 @@ FridaBox gratefully acknowledges the Frida maintainers and contributors for the
instrumentation engine, GumJS runtime, Java bridge, Gadget deployment model, and
controller APIs that make this research possible.
+## Tabler Icons
+
+FridaBox UI control icons are generated from the official `@tabler/icons`
+package version 3.45.0, sourced from `https://github.com/tabler/tabler-icons`.
+
+MIT License
+
+Copyright (c) 2020-2026 Paweł Kuna
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
## Frida Java bridge and agent compiler
The compiled JavaScript agents under `scripts/dist/` include
diff --git a/app/build.gradle b/app/build.gradle
index 35c6e14..45b0369 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -77,7 +77,6 @@ android {
jniLibs {
excludes.add("**/libandroidx.graphics.path.so")
useLegacyPackaging true
- keepDebugSymbols.add("**/libfrida-gadget.config.so")
}
resources {
excludes.add("**/libandroidx.graphics.path.so")
@@ -104,40 +103,15 @@ tasks.matching { it.name == "mergeDebugAssets" }.configureEach { dependsOn("copy
tasks.matching { it.name.toLowerCase().contains("debug") && it.name.toLowerCase().contains("lint") }
.configureEach { dependsOn("copyDemoGuestDebug") }
-def fridaGadget = file("src/main/jniLibs/arm64-v8a/libfrida-gadget.so")
-def fridaGadgetConfig = file("src/main/jniLibs/arm64-v8a/libfrida-gadget.config.so")
-tasks.register("verifyFridaGadget") {
- inputs.files(fridaGadget, fridaGadgetConfig)
- doLast {
- if (!fridaGadget.isFile()) {
- throw new GradleException("Frida Gadget 17.16.0 is missing. Run: python tools/fetch_frida_gadget.py")
- }
- if (!fridaGadgetConfig.isFile()) {
- throw new GradleException("Frida Gadget configuration is missing: ${fridaGadgetConfig}")
- }
- def digest = java.security.MessageDigest.getInstance("SHA-256")
- .digest(fridaGadget.bytes).collect { String.format("%02x", it & 0xff) }.join()
- if (digest != "6bf149e5d1c5ec701e7b822cab57bb243f1c2a03318fa974fe373ee711a9ed9e") {
- throw new GradleException("Frida Gadget 17.16.0 SHA-256 mismatch: ${digest}")
- }
- def config = new groovy.json.JsonSlurper().parse(fridaGadgetConfig)
- if (config.interaction?.address != "127.0.0.1" || config.interaction?.on_load != "wait") {
- throw new GradleException("Unsafe or invalid Frida Gadget configuration")
- }
- }
-}
-tasks.matching { it.name == "preBuild" }.configureEach { dependsOn("verifyFridaGadget") }
-
-tasks.register("verifyDebugApkFridaPackaging") {
+tasks.register("verifyDebugApkHasNoGadget") {
dependsOn("assembleDebug")
doLast {
def apk = fileTree("$buildDir/outputs/apk/debug").matching { include("*.apk") }.singleFile
- def entries = zipTree(apk).matching {
- include("lib/arm64-v8a/libfrida-gadget.so")
- include("lib/arm64-v8a/libfrida-gadget.config.so")
- }.files.collect { it.name }.toSet()
- if (!entries.contains("libfrida-gadget.so") || !entries.contains("libfrida-gadget.config.so")) {
- throw new GradleException("Debug APK does not contain both Frida Gadget files")
+ def entries = zipTree(apk).files.findAll {
+ it.name.toLowerCase().contains("gadget") && it.name.toLowerCase().endsWith(".so")
+ }
+ if (!entries.isEmpty()) {
+ throw new GradleException("APK must not bundle Frida Gadget: ${entries*.name}")
}
}
}
@@ -145,7 +119,7 @@ tasks.register("verifyDebugApkFridaPackaging") {
tasks.register("verifyInstrumentationOrdering") {
doLast {
def source = project(":Bcore").file("src/main/java/top/niunaijun/blackbox/app/BActivityThread.java").text
- def loader = source.indexOf("FridaGadgetLoader.loadIfEnabled()")
+ def loader = source.indexOf("FridaGadgetLoader.loadAtProcessBind()")
def makeApplication = source.indexOf("makeApplication(", loader)
if (loader < 0 || makeApplication < 0 || loader >= makeApplication) {
throw new GradleException("Frida Gadget loader must execute before guest makeApplication")
@@ -153,6 +127,17 @@ tasks.register("verifyInstrumentationOrdering") {
}
}
+tasks.register("verifyTablerAndroidIcons", Exec) {
+ workingDir(rootProject.projectDir)
+ def nodeExecutable = System.getProperty("os.name").toLowerCase().contains("windows") ? "node.exe" : "node"
+ commandLine(nodeExecutable, "tools/generate_tabler_android_icons.mjs", "--check")
+ doFirst {
+ if (!rootProject.file("node_modules/@tabler/icons/package.json").isFile()) {
+ throw new GradleException("Missing @tabler/icons. Run npm ci before building FridaBox.")
+ }
+ }
+}
+
tasks.register("verifyDemoGuestUnmodified") {
dependsOn("copyDemoGuestDebug")
doLast {
@@ -166,7 +151,8 @@ tasks.register("verifyDemoGuestUnmodified") {
}
tasks.named("check").configure {
- dependsOn("verifyDebugApkFridaPackaging", "verifyInstrumentationOrdering", "verifyDemoGuestUnmodified")
+ dependsOn("verifyDebugApkHasNoGadget", "verifyInstrumentationOrdering",
+ "verifyDemoGuestUnmodified", "verifyTablerAndroidIcons")
}
dependencies {
@@ -176,6 +162,7 @@ dependencies {
implementation libs.material
implementation libs.constraintlayout
implementation libs.core.ktx
+ implementation "org.tukaani:xz:1.10"
testImplementation libs.junit
androidTestImplementation libs.ext.junit
androidTestImplementation libs.espresso.core
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0108a1e..54aa5a2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
+
diff --git a/app/src/main/java/com/qm4rs/fridabox/ApkInspector.java b/app/src/main/java/com/qm4rs/fridabox/ApkInspector.java
index 255e3f2..aab920d 100644
--- a/app/src/main/java/com/qm4rs/fridabox/ApkInspector.java
+++ b/app/src/main/java/com/qm4rs/fridabox/ApkInspector.java
@@ -2,6 +2,7 @@ package com.qm4rs.fridabox;
import java.io.File;
import java.io.IOException;
+import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
@@ -13,23 +14,36 @@ public final class ApkInspector {
}
public static Result inspect(File apk) throws IOException {
+ return inspect(Collections.singletonList(apk));
+ }
+
+ public static Result inspect(File... apks) throws IOException {
+ return inspect(Arrays.asList(apks));
+ }
+
+ public static Result inspect(Iterable apks) throws IOException {
Set abis = new LinkedHashSet<>();
boolean hasNativeLibraries = false;
- try (ZipFile archive = new ZipFile(apk)) {
- if (archive.getEntry("AndroidManifest.xml") == null) {
- throw new IOException("The selected file has no AndroidManifest.xml");
- }
- for (ZipEntry entry : Collections.list(archive.entries())) {
- String name = entry.getName();
- if (!entry.isDirectory() && name.startsWith("lib/") && name.endsWith(".so")) {
- String[] parts = name.split("/", 3);
- if (parts.length == 3) {
- hasNativeLibraries = true;
- abis.add(parts[1]);
+ boolean inspectedAny = false;
+ for (File apk : apks) {
+ inspectedAny = true;
+ try (ZipFile archive = new ZipFile(apk)) {
+ if (archive.getEntry("AndroidManifest.xml") == null) {
+ throw new IOException("The selected file has no AndroidManifest.xml");
+ }
+ for (ZipEntry entry : Collections.list(archive.entries())) {
+ String name = entry.getName();
+ if (!entry.isDirectory() && name.startsWith("lib/") && name.endsWith(".so")) {
+ String[] parts = name.split("/", 3);
+ if (parts.length == 3) {
+ hasNativeLibraries = true;
+ abis.add(parts[1]);
+ }
}
}
}
}
+ if (!inspectedAny) throw new IOException("No APK files were provided");
boolean supported = !hasNativeLibraries || abis.contains("arm64-v8a");
return new Result(hasNativeLibraries, supported, abis);
}
diff --git a/app/src/main/java/com/qm4rs/fridabox/FridaBoxActivity.kt b/app/src/main/java/com/qm4rs/fridabox/FridaBoxActivity.kt
index b52c5e1..89f621c 100644
--- a/app/src/main/java/com/qm4rs/fridabox/FridaBoxActivity.kt
+++ b/app/src/main/java/com/qm4rs/fridabox/FridaBoxActivity.kt
@@ -1,5 +1,11 @@
package com.qm4rs.fridabox
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.animation.AnimatorSet
+import android.animation.ObjectAnimator
+import android.animation.PropertyValuesHolder
+import android.app.Dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
@@ -7,27 +13,37 @@ import android.content.SharedPreferences
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.res.ColorStateList
+import android.graphics.Color
import android.graphics.Typeface
+import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.GradientDrawable
import android.net.Uri
+import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns
import android.text.InputType
import android.view.Gravity
-import android.view.Menu
+import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
+import android.view.WindowManager
+import android.view.animation.AccelerateInterpolator
+import android.view.animation.OvershootInterpolator
+import android.widget.FrameLayout
+import android.widget.GridLayout
import android.widget.ImageView
import android.widget.LinearLayout
-import android.widget.PopupMenu
+import android.widget.PopupWindow
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.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
+import androidx.core.widget.NestedScrollView
import com.google.android.material.button.MaterialButton
import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.card.MaterialCardView
@@ -47,14 +63,25 @@ import java.util.concurrent.Executors
/** Product workspace for importing, configuring, and launching FridaBox guests. */
class FridaBoxActivity : AppCompatActivity() {
- private enum class Screen { WORKSPACE, RUNTIME, SETTINGS }
+ private enum class Screen { WORKSPACE, GADGETS, RUNTIME, SETTINGS }
+
+ private data class ImportBubbleViews(
+ val scrim: View,
+ val actions: List
+ )
private lateinit var binding: ActivityFridaboxBinding
private val worker = Executors.newSingleThreadExecutor()
private var screen = Screen.WORKSPACE
private var screenGeneration = 0
+ private var catalogRequest = 0
+ private var installedAppsRequest = 0
private var changingNavigation = false
private var pendingScriptPackage: String? = null
+ private var importMenuOverlay: FrameLayout? = null
+ private var importMenuAnimator: AnimatorSet? = null
+ private var importMenuAnimationGeneration = 0
+ private var importMenuClosing = false
@Suppress("DEPRECATION")
private val settings: SharedPreferences by lazy {
@@ -63,6 +90,7 @@ class FridaBoxActivity : AppCompatActivity() {
private val metadata: SharedPreferences by lazy {
getSharedPreferences("fridabox_imports", Context.MODE_PRIVATE)
}
+ private val gadgetManager: GadgetManager by lazy { GadgetManager(applicationContext) }
private val apkPicker = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) importApk(uri)
@@ -79,11 +107,12 @@ class FridaBoxActivity : AppCompatActivity() {
setContentView(binding.root)
pendingScriptPackage = savedInstanceState?.getString("pending_script_package")
- binding.importFab.setOnClickListener { openApkPicker() }
+ binding.importFabIcon.setOnClickListener { showImportActions() }
binding.bottomNavigation.setOnItemSelectedListener { item ->
if (changingNavigation) return@setOnItemSelectedListener true
when (item.itemId) {
R.id.nav_workspace -> showWorkspace()
+ R.id.nav_gadgets -> showGadgets()
R.id.nav_runtime -> showRuntime()
R.id.nav_settings -> showSettings()
else -> return@setOnItemSelectedListener false
@@ -92,6 +121,10 @@ class FridaBoxActivity : AppCompatActivity() {
}
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
+ if (importMenuOverlay != null) {
+ dismissImportActions()
+ return
+ }
if (screen == Screen.WORKSPACE) finish() else showWorkspace()
}
})
@@ -104,57 +137,64 @@ class FridaBoxActivity : AppCompatActivity() {
}
override fun onDestroy() {
+ dismissImportActions(immediate = true)
worker.shutdown()
super.onDestroy()
}
private fun showWorkspace() {
screen = Screen.WORKSPACE
- val generation = resetScreen(R.id.nav_workspace, showImport = false)
+ val generation = resetScreen(R.id.nav_workspace, showImport = true)
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 titleRow = LinearLayout(this).apply {
- orientation = LinearLayout.HORIZONTAL
- gravity = Gravity.CENTER_VERTICAL
- setPadding(0, dp(28), 0, dp(10))
+ val screenWidthDp = resources.configuration.screenWidthDp
+ val columns = when {
+ screenWidthDp < 600 -> 4
+ screenWidthDp < 840 -> 6
+ else -> 7
}
- 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)
+ val cellSizeDp = ((screenWidthDp - 36) / columns).coerceIn(78, 112)
+ val minimumRows = ((resources.configuration.screenHeightDp - 276) / cellSizeDp)
+ .coerceAtLeast(4)
+ val launcher = GridLayout(this).apply {
+ columnCount = columns
+ alignmentMode = GridLayout.ALIGN_BOUNDS
+ useDefaultMargins = false
+ setPadding(0, 0, 0, 0)
+ }
+ binding.content.addView(launcher, LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ))
setLoading(true)
worker.execute {
val result = runCatching {
BlackBoxCore.get().getInstalledPackages(PackageManager.GET_META_DATA, 0)
- .sortedBy { it.packageName }
+ .sortedBy { info ->
+ runCatching {
+ info.applicationInfo?.loadLabel(BlackBoxCore.getPackageManager())?.toString()
+ }.getOrNull().orEmpty().ifBlank { info.packageName }.lowercase(Locale.ROOT)
+ }
}
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)) }
+ packages.forEach { launcher.addView(appIcon(it, cellSizeDp)) }
+ val occupiedSlots = packages.size
+ val completedRows = (occupiedSlots + columns - 1) / columns
+ val totalSlots = maxOf(minimumRows, completedRows) * columns
+ repeat(totalSlots - occupiedSlots) {
+ launcher.addView(emptyLauncherCell(cellSizeDp))
+ }
}.onFailure { error ->
- guestList.addView(messageCard(
+ binding.content.addView(messageCard(
"Workspace unavailable",
error.message ?: "Unable to read virtual applications",
R.color.fb_error
@@ -164,54 +204,235 @@ class FridaBoxActivity : AppCompatActivity() {
}
}
- 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.logo)
- }, 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 appIcon(info: PackageInfo, cellSizeDp: Int): View {
+ val packageName = info.packageName
+ val appLabel = runCatching {
+ info.applicationInfo?.loadLabel(BlackBoxCore.getPackageManager())?.toString()
+ }.getOrNull().orEmpty().ifBlank { packageName.substringAfterLast('.') }
+ return FrameLayout(this).apply {
+ contentDescription = appLabel
+ isClickable = true
+ isFocusable = true
+ background = ContextCompat.getDrawable(this@FridaBoxActivity, R.drawable.bg_launcher_cell)
+ setOnClickListener { showAppLaunchDialog(info, this) }
+ addView(ImageView(this@FridaBoxActivity).apply {
+ runCatching {
+ setImageDrawable(info.applicationInfo?.loadIcon(BlackBoxCore.getPackageManager()))
+ }
+ scaleType = ImageView.ScaleType.FIT_CENTER
+ }, FrameLayout.LayoutParams(
+ dp((cellSizeDp * 0.62f).toInt().coerceIn(48, 64)),
+ dp((cellSizeDp * 0.62f).toInt().coerceIn(48, 64)),
+ Gravity.CENTER
+ ))
+ layoutParams = launcherCellLayoutParams(cellSizeDp)
}
}
- private fun appCard(info: PackageInfo): View {
+ private fun emptyLauncherCell(cellSizeDp: Int): View = View(this).apply {
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+ background = ContextCompat.getDrawable(this@FridaBoxActivity, R.drawable.bg_launcher_cell_empty)
+ layoutParams = launcherCellLayoutParams(cellSizeDp)
+ }
+
+ private fun launcherCellLayoutParams(cellSizeDp: Int): GridLayout.LayoutParams {
+ return GridLayout.LayoutParams().apply {
+ width = 0
+ height = dp(cellSizeDp)
+ columnSpec = GridLayout.spec(GridLayout.UNDEFINED, 1f)
+ setMargins(dp(4), dp(4), dp(4), dp(4))
+ }
+ }
+
+ private fun showAppLaunchDialog(info: PackageInfo, source: View) {
+ val dialog = Dialog(this)
+ val root = FrameLayout(this).apply {
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+ }
+ val scrim = View(this).apply {
+ alpha = 0f
+ setBackgroundColor(Color.BLACK)
+ }
+ root.addView(scrim, FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ ))
+
+ var dismissAnimated: ((() -> Unit)?) -> Unit = { after ->
+ dialog.dismiss()
+ resetAppIconMorph(source)
+ after?.invoke()
+ }
+ val panel = appCard(info) { after -> dismissAnimated(after) }.apply {
+ isClickable = true
+ alpha = 0f
+ }
+ val popoverWidth = minOf(
+ resources.displayMetrics.widthPixels - dp(24),
+ dp(300)
+ )
+ root.addView(panel, FrameLayout.LayoutParams(
+ popoverWidth,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ Gravity.TOP or Gravity.START
+ ).apply {
+ leftMargin = dp(12)
+ topMargin = dp(12)
+ })
+ scrim.setOnClickListener { dismissAnimated(null) }
+ dialog.setContentView(root)
+ dialog.setCanceledOnTouchOutside(false)
+ dialog.setCancelable(false)
+ dialog.setOnKeyListener { _, keyCode, event ->
+ if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
+ dismissAnimated(null)
+ true
+ } else {
+ false
+ }
+ }
+ dialog.setOnDismissListener { resetAppIconMorph(source) }
+ dialog.window?.apply {
+ setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT))
+ addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
+ attributes = attributes.apply { dimAmount = 0.18f }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
+ attributes = attributes.apply { blurBehindRadius = dp(24) }
+ }
+ }
+ dialog.show()
+ dialog.window?.setLayout(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+ animateAppIconMorph(source, opening = true)
+
+ root.post {
+ if (!dialog.isShowing || panel.width == 0 || panel.height == 0) return@post
+ val sourceLocation = IntArray(2)
+ val rootLocation = IntArray(2)
+ source.getLocationOnScreen(sourceLocation)
+ root.getLocationOnScreen(rootLocation)
+ val sourceCenterX = sourceLocation[0] - rootLocation[0] + source.width / 2f
+ val sourceCenterY = sourceLocation[1] - rootLocation[1] + source.height / 2f
+ val margin = dp(12)
+ val idealLeft = (sourceCenterX - dp(44)).toInt()
+ val idealTop = (sourceCenterY - dp(44)).toInt()
+ val maxLeft = (root.width - panel.width - margin).coerceAtLeast(margin)
+ val maxTop = (root.height - panel.height - margin).coerceAtLeast(margin)
+ panel.layoutParams = (panel.layoutParams as FrameLayout.LayoutParams).apply {
+ leftMargin = idealLeft.coerceIn(margin, maxLeft)
+ topMargin = idealTop.coerceIn(margin, maxTop)
+ }
+
+ panel.post panelReady@ {
+ if (!dialog.isShowing || !panel.isAttachedToWindow) return@panelReady
+ val pivotX = (sourceCenterX - panel.left).coerceIn(0f, panel.width.toFloat())
+ val pivotY = (sourceCenterY - panel.top).coerceIn(0f, panel.height.toFloat())
+ val startScale = (source.width.toFloat() / panel.width).coerceIn(0.18f, 0.28f)
+ panel.apply {
+ this.pivotX = pivotX
+ this.pivotY = pivotY
+ scaleX = startScale
+ scaleY = startScale
+ rotation = -0.8f
+ alpha = 0.42f
+ }
+ val body = panel.getChildAt(0) as? LinearLayout
+ val animatedChildren = body?.let { container ->
+ (0 until container.childCount).map { container.getChildAt(it) }
+ }.orEmpty()
+ animatedChildren.forEach { child ->
+ child.alpha = 0f
+ child.scaleX = 0.94f
+ child.scaleY = 0.94f
+ child.translationY = dp(12).toFloat()
+ }
+
+ addAppLaunchRipple(root, panel, sourceCenterX, sourceCenterY, delay = 0L)
+ var animation: AnimatorSet? = null
+ val openAnimators = mutableListOf(
+ ObjectAnimator.ofFloat(scrim, View.ALPHA, 0f, 0.14f).apply { duration = 190L },
+ ObjectAnimator.ofPropertyValuesHolder(
+ panel,
+ PropertyValuesHolder.ofFloat(View.ALPHA, 0.42f, 1f),
+ PropertyValuesHolder.ofFloat(View.SCALE_X, startScale, 1f),
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, startScale, 1f),
+ PropertyValuesHolder.ofFloat(View.ROTATION, -0.8f, 0f)
+ ).apply {
+ duration = 360L
+ interpolator = OvershootInterpolator(0.48f)
+ }
+ )
+ animatedChildren.forEachIndexed { index, child ->
+ openAnimators += ObjectAnimator.ofPropertyValuesHolder(
+ child,
+ PropertyValuesHolder.ofFloat(View.ALPHA, 0f, 1f),
+ PropertyValuesHolder.ofFloat(View.SCALE_X, 0.94f, 1f),
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.94f, 1f),
+ PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, dp(12).toFloat(), 0f)
+ ).apply {
+ duration = 215L
+ startDelay = 95L + index * 28L
+ interpolator = OvershootInterpolator(0.88f)
+ }
+ }
+ animation = AnimatorSet().apply {
+ playTogether(openAnimators)
+ start()
+ }
+
+ var closing = false
+ dismissAnimated = dismiss@ { after ->
+ if (closing) return@dismiss
+ closing = true
+ animation?.cancel()
+ animateAppIconMorph(source, opening = false)
+ val closeAnimators = mutableListOf(
+ ObjectAnimator.ofFloat(scrim, View.ALPHA, scrim.alpha, 0f).apply { duration = 150L },
+ ObjectAnimator.ofPropertyValuesHolder(
+ panel,
+ PropertyValuesHolder.ofFloat(View.ALPHA, panel.alpha, 0.18f),
+ PropertyValuesHolder.ofFloat(View.SCALE_X, panel.scaleX, startScale),
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, panel.scaleY, startScale),
+ PropertyValuesHolder.ofFloat(View.ROTATION, panel.rotation, 0.6f)
+ ).apply {
+ duration = 255L
+ interpolator = AccelerateInterpolator(1.15f)
+ }
+ )
+ animatedChildren.asReversed().forEachIndexed { index, child ->
+ closeAnimators += ObjectAnimator.ofPropertyValuesHolder(
+ child,
+ PropertyValuesHolder.ofFloat(View.ALPHA, child.alpha, 0f),
+ PropertyValuesHolder.ofFloat(View.SCALE_X, child.scaleX, 0.92f),
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, child.scaleY, 0.92f)
+ ).apply {
+ duration = 105L
+ startDelay = index * 18L
+ }
+ }
+ animation = AnimatorSet().apply {
+ playTogether(closeAnimators)
+ addListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animator: Animator) {
+ if (dialog.isShowing) dialog.dismiss()
+ resetAppIconMorph(source)
+ after?.invoke()
+ }
+ })
+ start()
+ }
+ }
+ }
+ }
+ }
+
+ private fun appCard(
+ info: PackageInfo,
+ dismiss: (after: (() -> Unit)?) -> Unit
+ ): MaterialCardView {
val packageName = info.packageName
val mode = InstrumentationSettings.getModeForPackage(packageName)
val appLabel = runCatching {
@@ -219,6 +440,8 @@ class FridaBoxActivity : AppCompatActivity() {
}.getOrNull().orEmpty().ifBlank { packageName.substringAfterLast('.') }
val card = surfaceCard().apply {
+ radius = dp(28).toFloat()
+ setCardBackgroundColor(color(R.color.fb_glass_surface_strong))
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
@@ -236,7 +459,7 @@ class FridaBoxActivity : AppCompatActivity() {
}
val icon = ImageView(this).apply {
runCatching { setImageDrawable(info.applicationInfo?.loadIcon(BlackBoxCore.getPackageManager())) }
- background = rounded(color(R.color.fb_surface_tint), dp(16))
+ background = rounded(color(R.color.fb_glass_surface_soft), dp(16))
setPadding(dp(8), dp(8), dp(8), dp(8))
}
heading.addView(icon, LinearLayout.LayoutParams(dp(58), dp(58)))
@@ -290,7 +513,7 @@ class FridaBoxActivity : AppCompatActivity() {
setPadding(0, dp(16), 0, 0)
}
actions.addView(primaryButton(getString(R.string.fb_launch)) {
- launchConfigured(packageName)
+ dismiss { launchConfigured(packageName) }
}, LinearLayout.LayoutParams(0, dp(50), 1f))
actions.addView(space(dp(10), 1))
actions.addView(outlineButton(getString(R.string.fb_more)) { anchor ->
@@ -300,6 +523,81 @@ class FridaBoxActivity : AppCompatActivity() {
return card
}
+ private fun animateAppIconMorph(source: View, opening: Boolean) {
+ source.animate().cancel()
+ val squash = ObjectAnimator.ofPropertyValuesHolder(
+ source,
+ PropertyValuesHolder.ofFloat(View.SCALE_X, source.scaleX, if (opening) 1.08f else 0.94f),
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, source.scaleY, if (opening) 0.88f else 1.06f)
+ ).apply { duration = 70L }
+ val stretch = ObjectAnimator.ofPropertyValuesHolder(
+ source,
+ PropertyValuesHolder.ofFloat(View.SCALE_X, if (opening) 1.08f else 0.94f, if (opening) 0.94f else 1.05f),
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, if (opening) 0.88f else 1.06f, if (opening) 1.06f else 0.95f),
+ PropertyValuesHolder.ofFloat(View.ALPHA, source.alpha, if (opening) 0.38f else 0.84f)
+ ).apply { duration = 85L }
+ val settle = ObjectAnimator.ofPropertyValuesHolder(
+ source,
+ PropertyValuesHolder.ofFloat(View.SCALE_X, if (opening) 0.94f else 1.05f, if (opening) 0.96f else 1f),
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, if (opening) 1.06f else 0.95f, if (opening) 0.96f else 1f),
+ PropertyValuesHolder.ofFloat(View.ALPHA, if (opening) 0.38f else 0.84f, if (opening) 0.22f else 1f)
+ ).apply {
+ duration = 145L
+ interpolator = OvershootInterpolator(0.9f)
+ }
+ AnimatorSet().apply {
+ playSequentially(squash, stretch, settle)
+ start()
+ }
+ }
+
+ private fun resetAppIconMorph(source: View) {
+ source.animate().cancel()
+ source.scaleX = 1f
+ source.scaleY = 1f
+ source.rotation = 0f
+ source.alpha = 1f
+ }
+
+ private fun addAppLaunchRipple(
+ root: FrameLayout,
+ panel: View,
+ centerX: Float,
+ centerY: Float,
+ delay: Long
+ ) {
+ val size = dp(72)
+ val ripple = View(this).apply {
+ alpha = 0f
+ scaleX = 0.28f
+ scaleY = 0.28f
+ background = GradientDrawable().apply {
+ shape = GradientDrawable.OVAL
+ setColor(Color.TRANSPARENT)
+ setStroke(dp(2), color(R.color.fb_glass_highlight))
+ }
+ }
+ root.addView(ripple, root.indexOfChild(panel).coerceAtLeast(1), FrameLayout.LayoutParams(size, size).apply {
+ leftMargin = (centerX - size / 2f).toInt()
+ topMargin = (centerY - size / 2f).toInt()
+ })
+ AnimatorSet().apply {
+ playTogether(
+ ObjectAnimator.ofFloat(ripple, View.ALPHA, 0f, 0.58f, 0f),
+ ObjectAnimator.ofFloat(ripple, View.SCALE_X, 0.28f, 1.32f),
+ ObjectAnimator.ofFloat(ripple, View.SCALE_Y, 0.28f, 1.32f)
+ )
+ startDelay = delay
+ duration = 410L
+ addListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ (ripple.parent as? ViewGroup)?.removeView(ripple)
+ }
+ })
+ start()
+ }
+ }
+
private fun renderModeInfo(container: LinearLayout, packageName: String, mode: String) {
container.removeAllViews()
val title: String
@@ -321,7 +619,7 @@ class FridaBoxActivity : AppCompatActivity() {
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))
+ background = rounded(color(R.color.fb_glass_surface_soft), dp(16))
}
panel.addView(labelText(title, R.color.fb_text_primary, 14f, true))
panel.addView(labelText(description, R.color.fb_text_secondary, 12.5f, false).apply {
@@ -355,6 +653,13 @@ class FridaBoxActivity : AppCompatActivity() {
private fun launchConfigured(packageName: String) {
val mode = InstrumentationSettings.getModeForPackage(packageName)
+ if (mode != InstrumentationSettings.MODE_CLEAN
+ && settings.getBoolean(InstrumentationSettings.KEY_ENABLED, true)
+ && gadgetManager.selected() == null) {
+ notify(getString(R.string.fb_gadget_required))
+ showGadgets()
+ return
+ }
if (mode == InstrumentationSettings.MODE_LOCAL_SCRIPT) {
val path = InstrumentationSettings.getScriptPathForPackage(packageName)
if (path.isNullOrBlank() || !File(path).isFile) {
@@ -365,12 +670,11 @@ class FridaBoxActivity : AppCompatActivity() {
return
}
if (mode == InstrumentationSettings.MODE_COMPUTER) {
- MaterialAlertDialogBuilder(this)
+ showGlassAlert(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()
+ .setNegativeButton(R.string.fb_cancel, null))
return
}
launch(packageName, mode)
@@ -406,7 +710,7 @@ class FridaBoxActivity : AppCompatActivity() {
}
private fun chooseAgent(packageName: String) {
- MaterialAlertDialogBuilder(this)
+ showGlassAlert(MaterialAlertDialogBuilder(this)
.setTitle(R.string.fb_choose_trusted_title)
.setMessage(R.string.fb_choose_trusted_body)
.setPositiveButton(R.string.fb_choose) { _, _ ->
@@ -418,8 +722,7 @@ class FridaBoxActivity : AppCompatActivity() {
"application/octet-stream"
))
}
- .setNegativeButton(R.string.fb_cancel, null)
- .show()
+ .setNegativeButton(R.string.fb_cancel, null))
}
private fun importAgent(packageName: String, uri: Uri) {
@@ -482,22 +785,41 @@ class FridaBoxActivity : AppCompatActivity() {
}
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()
+ val popup = PopupWindow(this).apply {
+ width = dp(250)
+ height = ViewGroup.LayoutParams.WRAP_CONTENT
+ isFocusable = true
+ isOutsideTouchable = true
+ setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT))
+ elevation = dp(14).toFloat()
}
+ val panel = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(10), dp(10), dp(10), dp(2))
+ background = ContextCompat.getDrawable(this@FridaBoxActivity, R.drawable.bg_glass_popup)
+ }
+ panel.addView(menuActionButton(getString(R.string.fb_details), R.drawable.ic_fb_details) {
+ popup.dismiss()
+ showAppDetails(info, appLabel)
+ })
+ panel.addView(menuActionButton(getString(R.string.fb_runtime_details), R.drawable.ic_fb_runtime) {
+ popup.dismiss()
+ showRuntime(info.packageName)
+ })
+ panel.addView(menuActionButton(getString(R.string.fb_clear_data), R.drawable.ic_fb_clear) {
+ popup.dismiss()
+ clearApp(info.packageName)
+ })
+ panel.addView(menuActionButton(getString(R.string.fb_remove_guest), R.drawable.ic_fb_remove) {
+ popup.dismiss()
+ removeApp(info.packageName, appLabel)
+ })
+ popup.contentView = panel
+ popup.showAtLocation(anchor, Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL, 0, dp(28))
+ }
+
+ private fun menuActionButton(label: String, iconResource: Int, action: () -> Unit): MaterialButton {
+ return importActionButton(label, iconResource, action)
}
private fun showAppDetails(info: PackageInfo, appLabel: String) {
@@ -510,11 +832,10 @@ class FridaBoxActivity : AppCompatActivity() {
append("\n\nSource\n").append(metadata.getString("$packageName.source", "Unknown"))
append("\n\nAPK SHA-256\n").append(metadata.getString("$packageName.sha256", "Unknown"))
}
- MaterialAlertDialogBuilder(this)
+ showGlassAlert(MaterialAlertDialogBuilder(this)
.setTitle(R.string.fb_details)
.setMessage(details)
- .setPositiveButton(android.R.string.ok, null)
- .show()
+ .setPositiveButton(android.R.string.ok, null))
}
private fun clearApp(packageName: String) {
@@ -533,7 +854,7 @@ class FridaBoxActivity : AppCompatActivity() {
}
private fun removeApp(packageName: String, appLabel: String) {
- MaterialAlertDialogBuilder(this)
+ showGlassAlert(MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.fb_remove_title))
.setMessage("$appLabel\n\n${getString(R.string.fb_remove_body)}")
.setPositiveButton(R.string.fb_remove) { _, _ ->
@@ -557,14 +878,508 @@ class FridaBoxActivity : AppCompatActivity() {
}
}
}
- .setNegativeButton(R.string.fb_cancel, null)
- .show()
+ .setNegativeButton(R.string.fb_cancel, null))
+ }
+
+ private fun showGlassAlert(builder: MaterialAlertDialogBuilder): AlertDialog {
+ return builder.create().also { dialog ->
+ dialog.setOnShowListener {
+ dialog.window?.apply {
+ setBackgroundDrawable(ContextCompat.getDrawable(
+ this@FridaBoxActivity,
+ R.drawable.bg_glass_popup
+ ))
+ addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
+ attributes = attributes.apply { dimAmount = 0.42f }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
+ attributes = attributes.apply { blurBehindRadius = dp(36) }
+ }
+ }
+ }
+ dialog.show()
+ }
}
private fun openApkPicker() {
apkPicker.launch(arrayOf("application/vnd.android.package-archive", "application/octet-stream"))
}
+ private fun showImportActions() {
+ if (importMenuOverlay != null) {
+ dismissImportActions()
+ return
+ }
+
+ val root = binding.root
+ val fab = binding.importFabIcon
+ if (fab.width == 0 || fab.height == 0 || root.width == 0) {
+ fab.post { if (!isFinishing) showImportActions() }
+ return
+ }
+
+ importMenuClosing = false
+ val overlay = FrameLayout(this).apply {
+ isClickable = true
+ isFocusable = true
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+ }
+ importMenuOverlay = overlay
+ root.addView(
+ overlay,
+ root.indexOfChild(fab).coerceAtLeast(0),
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+ )
+
+ val scrim = View(this).apply {
+ alpha = 0f
+ setBackgroundColor(Color.BLACK)
+ setOnClickListener { dismissImportActions() }
+ }
+ overlay.addView(scrim, FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ ))
+
+ val actionWidth = dp(232)
+ val actionHeight = dp(56)
+ val actionGap = dp(10)
+ val rightMargin = (root.width - fab.right).coerceAtLeast(dp(12))
+ val entries = listOf(
+ Triple(getString(R.string.fb_import_from_apps), R.drawable.ic_fb_apps) {
+ showInstalledAppsBrowser()
+ },
+ Triple(getString(R.string.fb_import_from_file), R.drawable.ic_fb_file) {
+ openApkPicker()
+ }
+ )
+ val actions = entries.mapIndexed { index, entry ->
+ val button = importBubbleActionButton(entry.first, entry.second) {
+ dismissImportActions(after = entry.third)
+ }
+ val distanceFromFab = entries.size - index
+ val desiredTop = fab.top - distanceFromFab * (actionHeight + actionGap)
+ overlay.addView(button, FrameLayout.LayoutParams(actionWidth, actionHeight, Gravity.TOP or Gravity.END).apply {
+ topMargin = desiredTop.coerceAtLeast(dp(12))
+ this.rightMargin = rightMargin
+ })
+ button.apply {
+ alpha = 0f
+ scaleX = 0.28f
+ scaleY = 0.28f
+ translationX = dp(16).toFloat()
+ translationY = dp(42).toFloat()
+ pivotX = actionWidth.toFloat()
+ pivotY = actionHeight.toFloat()
+ }
+ }
+ overlay.tag = ImportBubbleViews(scrim, actions)
+
+ addImportRipple(overlay, fab, delay = 0L)
+ addImportRipple(overlay, fab, delay = 90L)
+
+ val animationToken = ++importMenuAnimationGeneration
+ importMenuAnimator?.cancel()
+ val animators = mutableListOf(
+ importFabMorph(open = true),
+ ObjectAnimator.ofFloat(scrim, View.ALPHA, 0f, 0.18f).apply { duration = 240L }
+ )
+ actions.forEachIndexed { index, button ->
+ animators += ObjectAnimator.ofPropertyValuesHolder(
+ button,
+ PropertyValuesHolder.ofFloat(View.ALPHA, 0f, 1f),
+ PropertyValuesHolder.ofFloat(View.SCALE_X, 0.28f, 1f),
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.28f, 1f),
+ PropertyValuesHolder.ofFloat(View.TRANSLATION_X, dp(16).toFloat(), 0f),
+ PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, dp(42).toFloat(), 0f)
+ ).apply {
+ duration = 390L
+ startDelay = if (index == actions.lastIndex) 75L else 135L
+ interpolator = OvershootInterpolator(1.35f)
+ }
+ }
+ importMenuAnimator = AnimatorSet().apply {
+ playTogether(animators)
+ addListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ if (animationToken == importMenuAnimationGeneration) importMenuAnimator = null
+ }
+ })
+ start()
+ }
+ fab.announceForAccessibility(getString(R.string.fb_import))
+ }
+
+ private fun importBubbleActionButton(
+ label: String,
+ iconResource: Int,
+ action: () -> Unit
+ ): MaterialButton {
+ return importActionButton(label, iconResource, action).apply {
+ cornerRadius = dp(28)
+ backgroundTintList = ColorStateList.valueOf(color(R.color.fb_glass_surface_strong))
+ strokeColor = ColorStateList.valueOf(color(R.color.fb_glass_outline))
+ strokeWidth = dp(1)
+ elevation = dp(12).toFloat()
+ setPadding(dp(20), 0, dp(20), 0)
+ }
+ }
+
+ private fun dismissImportActions(
+ after: (() -> Unit)? = null,
+ immediate: Boolean = false
+ ) {
+ val overlay = importMenuOverlay ?: run {
+ after?.invoke()
+ return
+ }
+ if (importMenuClosing && !immediate) return
+ importMenuClosing = true
+ val animationToken = ++importMenuAnimationGeneration
+ importMenuAnimator?.cancel()
+ importMenuAnimator = null
+
+ val fab = binding.importFabIcon
+ val views = overlay.tag as? ImportBubbleViews
+ if (immediate || views == null || !overlay.isAttachedToWindow) {
+ (overlay.parent as? ViewGroup)?.removeView(overlay)
+ importMenuOverlay = null
+ importMenuClosing = false
+ resetImportFab()
+ after?.invoke()
+ return
+ }
+
+ val animators = mutableListOf(
+ importFabMorph(open = false),
+ ObjectAnimator.ofFloat(views.scrim, View.ALPHA, views.scrim.alpha, 0f).apply {
+ duration = 180L
+ }
+ )
+ views.actions.asReversed().forEachIndexed { index, button ->
+ animators += ObjectAnimator.ofPropertyValuesHolder(
+ button,
+ PropertyValuesHolder.ofFloat(View.ALPHA, button.alpha, 0f),
+ PropertyValuesHolder.ofFloat(View.SCALE_X, button.scaleX, 0.35f),
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, button.scaleY, 0.35f),
+ PropertyValuesHolder.ofFloat(View.TRANSLATION_X, button.translationX, dp(14).toFloat()),
+ PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, button.translationY, dp(36).toFloat())
+ ).apply {
+ duration = 190L
+ startDelay = index * 28L
+ interpolator = AccelerateInterpolator(1.4f)
+ }
+ }
+ importMenuAnimator = AnimatorSet().apply {
+ playTogether(animators)
+ addListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ if (animationToken != importMenuAnimationGeneration) return
+ (overlay.parent as? ViewGroup)?.removeView(overlay)
+ importMenuOverlay = null
+ importMenuAnimator = null
+ importMenuClosing = false
+ resetImportFab()
+ after?.invoke()
+ }
+ })
+ start()
+ }
+ }
+
+ private fun importFabMorph(open: Boolean): AnimatorSet {
+ val fab = binding.importFabIcon
+ val squash = ObjectAnimator.ofPropertyValuesHolder(
+ fab,
+ PropertyValuesHolder.ofFloat(View.SCALE_X, fab.scaleX, if (open) 1.22f else 0.82f),
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, fab.scaleY, if (open) 0.72f else 1.18f),
+ PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, fab.translationY, dp(4).toFloat())
+ ).apply { duration = 95L }
+ val stretch = ObjectAnimator.ofPropertyValuesHolder(
+ fab,
+ PropertyValuesHolder.ofFloat(View.SCALE_X, if (open) 1.22f else 0.82f, if (open) 0.82f else 1.18f),
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, if (open) 0.72f else 1.18f, if (open) 1.20f else 0.78f),
+ PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, dp(4).toFloat(), -dp(3).toFloat()),
+ PropertyValuesHolder.ofFloat(View.ROTATION, fab.rotation, if (open) 145f else 35f)
+ ).apply {
+ duration = 115L
+ addListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationStart(animation: Animator) {
+ fab.setImageResource(if (open) R.drawable.ic_fb_close else R.drawable.ic_fb_import)
+ fab.contentDescription = getString(if (open) R.string.fb_close else R.string.fb_import)
+ }
+ })
+ }
+ val settle = ObjectAnimator.ofPropertyValuesHolder(
+ fab,
+ PropertyValuesHolder.ofFloat(View.SCALE_X, if (open) 0.82f else 1.18f, 1f),
+ PropertyValuesHolder.ofFloat(View.SCALE_Y, if (open) 1.20f else 0.78f, 1f),
+ PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -dp(3).toFloat(), 0f),
+ PropertyValuesHolder.ofFloat(View.ROTATION, if (open) 145f else 35f, if (open) 180f else 0f)
+ ).apply {
+ duration = 210L
+ interpolator = OvershootInterpolator(1.55f)
+ }
+ return AnimatorSet().apply { playSequentially(squash, stretch, settle) }
+ }
+
+ private fun addImportRipple(overlay: FrameLayout, fab: View, delay: Long) {
+ val rippleSize = dp(78)
+ val ripple = View(this).apply {
+ alpha = 0f
+ scaleX = 0.35f
+ scaleY = 0.35f
+ background = GradientDrawable().apply {
+ shape = GradientDrawable.OVAL
+ setColor(Color.TRANSPARENT)
+ setStroke(dp(2), color(R.color.fb_glass_highlight))
+ }
+ }
+ overlay.addView(ripple, FrameLayout.LayoutParams(rippleSize, rippleSize).apply {
+ leftMargin = fab.left + (fab.width - rippleSize) / 2
+ topMargin = fab.top + (fab.height - rippleSize) / 2
+ })
+ AnimatorSet().apply {
+ playTogether(
+ ObjectAnimator.ofFloat(ripple, View.ALPHA, 0f, 0.72f, 0f),
+ ObjectAnimator.ofFloat(ripple, View.SCALE_X, 0.35f, 1.55f),
+ ObjectAnimator.ofFloat(ripple, View.SCALE_Y, 0.35f, 1.55f)
+ )
+ startDelay = delay
+ duration = 520L
+ addListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ (ripple.parent as? ViewGroup)?.removeView(ripple)
+ }
+ })
+ start()
+ }
+ }
+
+ private fun resetImportFab() {
+ if (!::binding.isInitialized) return
+ binding.importFabIcon.apply {
+ animate().cancel()
+ setImageResource(R.drawable.ic_fb_import)
+ contentDescription = getString(R.string.fb_import)
+ scaleX = 1f
+ scaleY = 1f
+ translationY = 0f
+ rotation = 0f
+ }
+ }
+
+ private fun importActionButton(label: String, iconResource: Int, action: () -> Unit): MaterialButton {
+ return outlineButton(label) { action() }.apply {
+ icon = ContextCompat.getDrawable(this@FridaBoxActivity, iconResource)
+ iconTint = ColorStateList.valueOf(color(R.color.fb_text_primary))
+ iconPadding = dp(12)
+ iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
+ gravity = Gravity.START or Gravity.CENTER_VERTICAL
+ layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(50)).apply {
+ bottomMargin = dp(8)
+ }
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun showInstalledAppsBrowser() {
+ val dialog = Dialog(this)
+ val root = FrameLayout(this).apply {
+ setPadding(dp(12), dp(32), dp(12), dp(12))
+ setOnClickListener { dialog.dismiss() }
+ }
+ val panel = surfaceCard().apply {
+ radius = dp(28).toFloat()
+ setCardBackgroundColor(color(R.color.fb_glass_surface_strong))
+ isClickable = true
+ }
+ val body = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(18), dp(18), dp(18), dp(10))
+ }
+ panel.addView(body)
+ body.addView(LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ addView(labelText(
+ getString(R.string.fb_installed_apps),
+ R.color.fb_text_primary,
+ 19f,
+ true
+ ), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
+ addView(iconButton(R.drawable.ic_fb_close, getString(R.string.fb_close)) { dialog.dismiss() },
+ LinearLayout.LayoutParams(dp(40), dp(40)))
+ })
+
+ val list = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(0, dp(10), 0, 0)
+ }
+ val scroll = NestedScrollView(this).apply {
+ isFillViewport = false
+ overScrollMode = View.OVER_SCROLL_NEVER
+ addView(list, ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ))
+ }
+ body.addView(scroll, LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ 0,
+ 1f
+ ))
+ val footer = labelText(
+ getString(R.string.fb_loading_apps),
+ R.color.fb_text_secondary,
+ 12f,
+ false
+ ).apply {
+ gravity = Gravity.CENTER
+ setPadding(0, dp(14), 0, dp(8))
+ }
+ body.addView(footer)
+
+ root.addView(panel, FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ (resources.displayMetrics.heightPixels * 0.82f).toInt(),
+ Gravity.BOTTOM
+ ))
+ dialog.setContentView(root)
+ dialog.setCanceledOnTouchOutside(true)
+ dialog.window?.apply {
+ setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT))
+ addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
+ attributes = attributes.apply { dimAmount = 0.42f }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
+ attributes = attributes.apply { blurBehindRadius = dp(36) }
+ }
+ }
+ dialog.show()
+ dialog.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
+
+ val request = ++installedAppsRequest
+ worker.execute {
+ val result = runCatching {
+ val imported = BlackBoxCore.get()
+ .getInstalledPackages(0, 0)
+ .mapTo(HashSet()) { it.packageName }
+ val packages = packageManager.getInstalledPackages(PackageManager.GET_META_DATA)
+ .asSequence()
+ .filter { it.packageName != packageName }
+ .filter { it.applicationInfo?.enabled == true }
+ .filter { packageManager.getLaunchIntentForPackage(it.packageName) != null }
+ .filter { info ->
+ info.applicationInfo?.sourceDir?.let { File(it).isFile } == true
+ }
+ .sortedBy { info ->
+ info.applicationInfo?.loadLabel(packageManager)?.toString()
+ .orEmpty().ifBlank { info.packageName }.lowercase(Locale.ROOT)
+ }
+ .toList()
+ packages to imported
+ }
+ runOnUiThread {
+ if (request != installedAppsRequest || !dialog.isShowing || isFinishing) {
+ return@runOnUiThread
+ }
+ result.onSuccess { (packages, imported) ->
+ footer.isVisible = packages.isEmpty()
+ if (packages.isEmpty()) footer.text = getString(R.string.fb_no_importable_apps)
+ packages.forEach { info ->
+ list.addView(installedAppRow(info, info.packageName in imported, dialog))
+ }
+ }.onFailure { error ->
+ footer.text = error.message ?: getString(R.string.fb_no_importable_apps)
+ }
+ }
+ }
+ }
+
+ private fun installedAppRow(info: PackageInfo, imported: Boolean, dialog: Dialog): View {
+ val appInfo = info.applicationInfo
+ val label = runCatching { appInfo?.loadLabel(packageManager)?.toString() }
+ .getOrNull().orEmpty().ifBlank { info.packageName }
+ return LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ setPadding(0, dp(9), 0, dp(9))
+ addView(ImageView(this@FridaBoxActivity).apply {
+ runCatching { setImageDrawable(appInfo?.loadIcon(packageManager)) }
+ scaleType = ImageView.ScaleType.FIT_CENTER
+ }, LinearLayout.LayoutParams(dp(46), dp(46)))
+ addView(LinearLayout(this@FridaBoxActivity).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(12), 0, dp(8), 0)
+ addView(labelText(label, R.color.fb_text_primary, 14.5f, true).apply {
+ maxLines = 1
+ ellipsize = android.text.TextUtils.TruncateAt.END
+ })
+ addView(labelText(info.packageName, R.color.fb_text_secondary, 10.5f, false).apply {
+ maxLines = 1
+ ellipsize = android.text.TextUtils.TruncateAt.END
+ })
+ }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
+ addView(outlineButton(getString(
+ if (imported) R.string.fb_app_imported else R.string.fb_import_app
+ )) {
+ dialog.dismiss()
+ importInstalledApp(info.packageName)
+ }.apply {
+ isEnabled = !imported
+ }, LinearLayout.LayoutParams(dp(92), dp(40)))
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun importInstalledApp(packageName: String) {
+ setLoading(true)
+ worker.execute {
+ val result = runCatching {
+ require(packageName != this.packageName) { "FridaBox cannot import itself" }
+ val info = packageManager.getPackageInfo(packageName, PackageManager.GET_META_DATA)
+ val appInfo = requireNotNull(info.applicationInfo) { "Application metadata is unavailable" }
+ val apkFiles = buildList {
+ appInfo.sourceDir?.let { add(File(it)) }
+ appInfo.splitSourceDirs?.forEach { add(File(it)) }
+ }.distinctBy { it.canonicalPath }
+ require(apkFiles.isNotEmpty() && apkFiles.all { it.isFile }) {
+ "Installed APK files are unavailable"
+ }
+ val abi = ApkInspector.inspect(apkFiles)
+ if (!abi.supported) error("Unsupported native ABI: ${abi.description()}")
+ val originalHash = installedPackageSha256(apkFiles)
+ val install = BlackBoxCore.get().installPackageAsUser(packageName, 0)
+ if (!install.success) error(install.msg ?: "Virtual installation failed")
+ if (installedPackageSha256(apkFiles) != originalHash) {
+ error("Installed package changed during import")
+ }
+ metadata.edit()
+ .putString("$packageName.sha256", originalHash)
+ .putString("$packageName.source", appInfo.sourceDir)
+ .putString("$packageName.abi", abi.description())
+ .putString("$packageName.version", info.versionName)
+ .putInt("$packageName.targetSdk", appInfo.targetSdkVersion)
+ .putInt("$packageName.apkCount", apkFiles.size)
+ .putBoolean("$packageName.fromInstalledApp", true)
+ .apply()
+ InstrumentationSettings.setModeForPackage(packageName, InstrumentationSettings.MODE_COMPUTER)
+ appInfo.loadLabel(packageManager).toString().ifBlank { packageName }
+ }
+ runOnUiThread {
+ setLoading(false)
+ result.onSuccess {
+ notify("$it imported from installed apps")
+ showWorkspace()
+ }.onFailure { notify("App import failed: ${it.message}") }
+ }
+ }
+ }
+
private fun importApk(uri: Uri) {
val name = displayName(uri, "selected.apk")
val lowerName = name.lowercase(Locale.ROOT)
@@ -629,6 +1444,283 @@ class FridaBoxActivity : AppCompatActivity() {
}
}
+ private fun showGadgets() {
+ screen = Screen.GADGETS
+ resetScreen(R.id.nav_gadgets, showImport = false)
+ binding.toolbar.title = getString(R.string.fb_gadgets_title)
+
+ val abi = gadgetManager.detectedAbi()
+ if (abi == null) {
+ binding.toolbar.subtitle = null
+ binding.content.addView(messageCard(
+ getString(R.string.fb_gadget_unsupported_title),
+ Build.SUPPORTED_ABIS.joinToString(),
+ R.color.fb_error
+ ))
+ return
+ }
+ binding.toolbar.subtitle = abi.androidName
+
+ val selected = gadgetManager.selected()
+ binding.content.addView(LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(0, dp(8), 0, dp(20))
+ addView(labelText(getString(R.string.fb_active_gadget), R.color.fb_text_secondary, 12f, false))
+ addView(labelText(
+ selected?.version ?: getString(R.string.fb_no_active_gadget_short),
+ R.color.fb_text_primary,
+ 22f,
+ true
+ ).apply { setPadding(0, dp(3), 0, 0) })
+ if (selected != null) {
+ addView(labelText(
+ "${selected.source.title} / ${selected.abi.androidName}",
+ R.color.fb_text_secondary,
+ 12.5f,
+ false
+ ).apply { setPadding(0, dp(3), 0, 0) })
+ }
+ })
+
+ binding.content.addView(primaryButton(getString(R.string.fb_browse_gadgets)) {
+ showGadgetBrowser(abi)
+ }.apply {
+ layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(52)).apply {
+ bottomMargin = dp(24)
+ }
+ })
+
+ val installed = gadgetManager.installed()
+ if (installed.isNotEmpty()) {
+ binding.content.addView(labelText(
+ getString(R.string.fb_installed_gadgets), R.color.fb_text_secondary, 12f, false
+ ).apply { setPadding(0, 0, 0, dp(6)) })
+ installed.forEach { gadget ->
+ binding.content.addView(installedGadgetRow(gadget, selected?.identity == gadget.identity))
+ }
+ }
+ }
+
+ private fun installedGadgetRow(gadget: InstalledGadget, active: Boolean): View {
+ return gadgetRow(
+ gadget.version,
+ "${gadget.abi.androidName} / ${humanSize(gadget.size)}",
+ if (active) getString(R.string.fb_gadget_active) else getString(R.string.fb_use_gadget),
+ active
+ ) {
+ runCatching { gadgetManager.select(gadget) }
+ .onSuccess {
+ notify(getString(R.string.fb_gadget_selected, gadget.version))
+ showGadgets()
+ }
+ .onFailure { notify(it.message ?: getString(R.string.fb_gadget_select_failed)) }
+ }
+ }
+
+ private fun releaseRow(release: GadgetRelease, dialog: Dialog): View {
+ val installed = gadgetManager.findInstalled(release)
+ val activePath = InstrumentationSettings.getSelectedGadgetPath()
+ val active = installed != null && runCatching {
+ installed.file.canonicalPath == File(activePath.orEmpty()).canonicalPath
+ }.getOrDefault(false)
+ val details = listOfNotNull(
+ release.publishedAt.takeIf { it.isNotBlank() }?.take(10),
+ release.compressedSize.takeIf { it > 0 }?.let(::humanSize)
+ ).joinToString(" / ")
+ return gadgetRow(
+ release.version,
+ details,
+ when {
+ active -> getString(R.string.fb_gadget_active)
+ installed != null -> getString(R.string.fb_use_gadget)
+ else -> getString(R.string.fb_download)
+ },
+ active
+ ) {
+ when {
+ installed != null -> runCatching { gadgetManager.select(installed) }
+ .onSuccess {
+ dialog.dismiss()
+ showGadgets()
+ }
+ .onFailure { notify(it.message ?: getString(R.string.fb_gadget_select_failed)) }
+ else -> {
+ dialog.dismiss()
+ downloadGadget(release)
+ }
+ }
+ }
+ }
+
+ private fun gadgetRow(
+ version: String,
+ details: String,
+ actionLabel: String,
+ actionDisabled: Boolean,
+ action: () -> Unit
+ ): View = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ setPadding(0, dp(10), 0, dp(10))
+ addView(LinearLayout(this@FridaBoxActivity).apply {
+ orientation = LinearLayout.VERTICAL
+ addView(labelText(version, R.color.fb_text_primary, 16f, true))
+ if (details.isNotBlank()) {
+ addView(labelText(details, R.color.fb_text_secondary, 11.5f, false).apply {
+ setPadding(0, dp(2), 0, 0)
+ })
+ }
+ }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
+ addView(outlineButton(actionLabel) { action() }.apply {
+ isEnabled = !actionDisabled
+ }, LinearLayout.LayoutParams(dp(106), dp(40)).apply { marginStart = dp(12) })
+ }
+
+ private fun showGadgetBrowser(abi: GadgetAbi) {
+ val dialog = Dialog(this)
+ val root = FrameLayout(this).apply {
+ setPadding(dp(12), dp(32), dp(12), dp(12))
+ setOnClickListener { dialog.dismiss() }
+ }
+ val panel = surfaceCard().apply {
+ radius = dp(28).toFloat()
+ setCardBackgroundColor(color(R.color.fb_glass_surface_strong))
+ isClickable = true
+ }
+ val body = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(18), dp(18), dp(18), dp(10))
+ }
+ panel.addView(body)
+
+ body.addView(LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ addView(verticalText(
+ getString(R.string.fb_browse_gadgets),
+ abi.androidName
+ ), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
+ addView(iconButton(R.drawable.ic_fb_close, getString(R.string.fb_close)) { dialog.dismiss() },
+ LinearLayout.LayoutParams(dp(40), dp(40)))
+ })
+
+ val list = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(0, dp(10), 0, 0)
+ }
+ val scroll = NestedScrollView(this).apply {
+ isFillViewport = false
+ overScrollMode = View.OVER_SCROLL_NEVER
+ addView(list, ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ))
+ }
+ body.addView(scroll, LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ 0,
+ 1f
+ ))
+ val footer = labelText("", R.color.fb_text_secondary, 12f, false).apply {
+ gravity = Gravity.CENTER
+ setPadding(0, dp(10), 0, dp(4))
+ isVisible = false
+ }
+ body.addView(footer)
+
+ root.addView(panel, FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ (resources.displayMetrics.heightPixels * 0.82f).toInt(),
+ Gravity.BOTTOM
+ ))
+ dialog.setContentView(root)
+ dialog.setCanceledOnTouchOutside(true)
+ dialog.window?.apply {
+ setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT))
+ addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
+ attributes = attributes.apply { dimAmount = 0.42f }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
+ attributes = attributes.apply { blurBehindRadius = dp(36) }
+ }
+ }
+ dialog.show()
+ dialog.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
+
+ val request = ++catalogRequest
+ val identities = HashSet()
+ var page = 1
+ var loading = false
+ var hasMore = true
+ val loader = object : Runnable {
+ override fun run() {
+ if (loading || !hasMore || !dialog.isShowing) return
+ loading = true
+ footer.text = getString(R.string.fb_loading_versions)
+ footer.isVisible = true
+ footer.setOnClickListener(null)
+ worker.execute {
+ val result = runCatching {
+ gadgetManager.loadCatalog(GadgetSource.OFFICIAL, abi, page)
+ }
+ runOnUiThread {
+ if (request != catalogRequest || !dialog.isShowing || isFinishing) {
+ return@runOnUiThread
+ }
+ loading = false
+ result.onSuccess { catalog ->
+ catalog.releases.forEach { release ->
+ val identity = "${release.source.id}:${release.version}:${release.abi.androidName}"
+ if (identities.add(identity)) list.addView(releaseRow(release, dialog))
+ }
+ page += 1
+ hasMore = catalog.hasMore
+ footer.text = when {
+ !hasMore -> getString(R.string.fb_all_versions_loaded)
+ catalog.fromCache -> getString(R.string.fb_cached_versions)
+ else -> ""
+ }
+ footer.isVisible = !hasMore || catalog.fromCache
+ if (hasMore && (catalog.releases.isEmpty() || !scroll.canScrollVertically(1))) {
+ scroll.post(this)
+ }
+ }.onFailure { error ->
+ footer.text = error.message ?: getString(R.string.fb_catalog_failed_body)
+ footer.isVisible = true
+ footer.setOnClickListener { run() }
+ }
+ }
+ }
+ }
+ }
+ scroll.setOnScrollChangeListener { view: NestedScrollView, _, scrollY, _, _ ->
+ val child = view.getChildAt(0) ?: return@setOnScrollChangeListener
+ if (child.bottom - (view.height + scrollY) <= dp(120)) loader.run()
+ }
+ loader.run()
+ }
+
+ private fun downloadGadget(release: GadgetRelease) {
+ setLoading(true)
+ notify(getString(R.string.fb_gadget_downloading, release.version))
+ worker.execute {
+ val hadSelection = gadgetManager.selected() != null
+ val result = runCatching {
+ gadgetManager.download(release).also { if (!hadSelection) gadgetManager.select(it) }
+ }
+ runOnUiThread {
+ setLoading(false)
+ result.onSuccess {
+ notify(getString(
+ if (hadSelection) R.string.fb_gadget_downloaded else R.string.fb_gadget_downloaded_selected,
+ release.version
+ ))
+ showGadgets()
+ }.onFailure { notify(getString(R.string.fb_gadget_download_failed, it.message ?: "Unknown error")) }
+ }
+ }
+ }
+
private fun showRuntime(packageHint: String? = null) {
screen = Screen.RUNTIME
resetScreen(R.id.nav_runtime, showImport = false)
@@ -772,7 +1864,8 @@ class FridaBoxActivity : AppCompatActivity() {
return TextInputLayout(this).apply {
hint = label
boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE
- boxStrokeColor = color(R.color.fb_outline)
+ boxBackgroundColor = color(R.color.fb_glass_surface_soft)
+ boxStrokeColor = color(R.color.fb_glass_outline)
defaultHintTextColor = ColorStateList.valueOf(color(R.color.fb_text_secondary))
setPadding(0, dp(12), 0, 0)
addView(input)
@@ -856,9 +1949,9 @@ class FridaBoxActivity : AppCompatActivity() {
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)
+ cardElevation = dp(8).toFloat()
+ setCardBackgroundColor(color(R.color.fb_glass_surface))
+ strokeColor = color(R.color.fb_glass_outline)
strokeWidth = dp(1)
}
@@ -894,8 +1987,8 @@ class FridaBoxActivity : AppCompatActivity() {
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))
+ strokeColor = checkedColors(color(R.color.fb_primary), color(R.color.fb_glass_outline))
+ backgroundTintList = checkedColors(color(R.color.fb_primary), color(R.color.fb_glass_surface_soft))
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)
}
}
@@ -905,11 +1998,14 @@ class FridaBoxActivity : AppCompatActivity() {
text = label
isAllCaps = false
letterSpacing = 0f
- cornerRadius = dp(15)
+ cornerRadius = dp(18)
insetTop = 0
insetBottom = 0
setTextColor(color(R.color.fb_black))
backgroundTintList = ColorStateList.valueOf(color(R.color.fb_primary))
+ strokeColor = ColorStateList.valueOf(color(R.color.fb_glass_highlight))
+ strokeWidth = dp(1)
+ elevation = dp(3).toFloat()
setOnClickListener(action)
}
}
@@ -919,21 +2015,34 @@ class FridaBoxActivity : AppCompatActivity() {
text = label
isAllCaps = false
letterSpacing = 0f
- cornerRadius = dp(15)
+ cornerRadius = dp(18)
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))
+ strokeColor = ColorStateList.valueOf(color(R.color.fb_glass_outline))
+ backgroundTintList = ColorStateList.valueOf(color(R.color.fb_glass_surface_soft))
setOnClickListener(action)
}
}
+ private fun iconButton(iconResource: Int, description: String, action: () -> Unit): MaterialButton {
+ return outlineButton("") { action() }.apply {
+ contentDescription = description
+ icon = ContextCompat.getDrawable(this@FridaBoxActivity, iconResource)
+ iconTint = ColorStateList.valueOf(color(R.color.fb_text_primary))
+ iconPadding = 0
+ iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
+ minWidth = 0
+ setPadding(dp(8), 0, dp(8), 0)
+ }
+ }
+
private fun resetScreen(navId: Int, showImport: Boolean): Int {
+ dismissImportActions(immediate = true)
screenGeneration += 1
binding.content.removeAllViews()
binding.contentScroll.scrollTo(0, 0)
- binding.importFab.isVisible = showImport
+ binding.importFabIcon.isVisible = showImport
changingNavigation = true
binding.bottomNavigation.selectedItemId = navId
changingNavigation = false
@@ -976,9 +2085,9 @@ class FridaBoxActivity : AppCompatActivity() {
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
+ InstrumentationSettings.MODE_LOCAL_SCRIPT -> if (background) R.color.fb_glass_surface_soft else R.color.fb_success
+ InstrumentationSettings.MODE_CLEAN -> if (background) R.color.fb_glass_surface_soft else R.color.fb_text_secondary
+ else -> if (background) R.color.fb_glass_surface_soft else R.color.fb_warning
}
return color(resource)
}
@@ -1014,15 +2123,47 @@ class FridaBoxActivity : AppCompatActivity() {
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 notify(message: String) {
+ Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).apply {
+ setTextColor(color(R.color.fb_text_primary))
+ view.background = ContextCompat.getDrawable(this@FridaBoxActivity, R.drawable.bg_glass_popup)
+ view.elevation = dp(12).toFloat()
+ show()
+ }
+ }
private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_LONG).show()
private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
+ private fun humanSize(bytes: Long): String {
+ if (bytes < 1024L) return "$bytes B"
+ val units = arrayOf("KiB", "MiB", "GiB")
+ var value = bytes.toDouble()
+ var unit = -1
+ while (value >= 1024.0 && unit < units.lastIndex) {
+ value /= 1024.0
+ unit++
+ }
+ return String.format(Locale.ROOT, "%.1f %s", value, units[unit])
+ }
+
+ private fun installedPackageSha256(apks: List): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ apks.sortedBy { it.name }.forEach { apk ->
+ digest.update(apk.name.toByteArray(Charsets.UTF_8))
+ digest.update(byteArrayOf(0))
+ apk.inputStream().use { input ->
+ val buffer = ByteArray(128 * 1024)
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ digest.update(buffer, 0, count)
+ }
+ }
+ }
+ return digest.digest().joinToString("") { "%02x".format(it) }
+ }
+
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/java/com/qm4rs/fridabox/GadgetManager.kt b/app/src/main/java/com/qm4rs/fridabox/GadgetManager.kt
new file mode 100644
index 0000000..38f3d7e
--- /dev/null
+++ b/app/src/main/java/com/qm4rs/fridabox/GadgetManager.kt
@@ -0,0 +1,382 @@
+package com.qm4rs.fridabox
+
+import android.content.Context
+import android.os.Build
+import org.json.JSONArray
+import org.json.JSONObject
+import org.tukaani.xz.XZInputStream
+import top.niunaijun.blackbox.instrumentation.InstrumentationSettings
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.net.HttpURLConnection
+import java.net.URL
+import java.security.MessageDigest
+import java.util.Locale
+import java.util.zip.GZIPInputStream
+
+enum class GadgetSource(
+ val id: String,
+ val title: String,
+ val repository: String
+) {
+ OFFICIAL("official", "Official Frida", "frida/frida");
+
+ fun expectedAsset(version: String, architecture: String): String =
+ "frida-gadget-$version-android-$architecture.so.xz"
+
+ companion object {
+ fun fromId(value: String?): GadgetSource? = entries.firstOrNull { it.id == value }
+ }
+}
+
+data class GadgetAbi(val androidName: String, val releaseName: String) {
+ companion object {
+ private val supported = listOf(
+ GadgetAbi("arm64-v8a", "arm64"),
+ GadgetAbi("armeabi-v7a", "arm"),
+ GadgetAbi("x86_64", "x86_64"),
+ GadgetAbi("x86", "x86")
+ )
+
+ fun detect(): GadgetAbi? = Build.SUPPORTED_ABIS
+ .firstNotNullOfOrNull { deviceAbi -> supported.firstOrNull { it.androidName == deviceAbi } }
+ }
+}
+
+data class GadgetRelease(
+ val source: GadgetSource,
+ val version: String,
+ val abi: GadgetAbi,
+ val assetName: String,
+ val downloadUrl: String,
+ val compressedSize: Long,
+ val publishedAt: String
+)
+
+data class InstalledGadget(
+ val source: GadgetSource,
+ val version: String,
+ val abi: GadgetAbi,
+ val file: File,
+ val sha256: String,
+ val size: Long
+) {
+ val identity: String get() = "${source.id}:$version:${abi.androidName}"
+}
+
+data class GadgetCatalog(
+ val releases: List,
+ val fromCache: Boolean,
+ val hasMore: Boolean
+)
+
+class GadgetManager(private val context: Context) {
+ private val root = File(context.filesDir, DOWNLOAD_ROOT)
+ private val cacheRoot = File(context.cacheDir, "gadget-catalogs")
+
+ init {
+ if (InstrumentationSettings.getSelectedGadgetSource() != null
+ && InstrumentationSettings.getSelectedGadgetSource() != GadgetSource.OFFICIAL.id) {
+ InstrumentationSettings.clearSelectedGadget()
+ }
+ }
+
+ fun detectedAbi(): GadgetAbi? = GadgetAbi.detect()
+
+ fun loadCatalog(source: GadgetSource, abi: GadgetAbi, page: Int): GadgetCatalog {
+ val cache = File(cacheRoot, "${source.id}-${abi.androidName}-$page.json")
+ return try {
+ val json = requestText(releaseCatalogUrl(source, page))
+ val releases = parseCatalog(json, source, abi)
+ writeAtomically(cache, json.toByteArray(Charsets.UTF_8))
+ GadgetCatalog(releases, false, JSONArray(json).length() == CATALOG_PAGE_SIZE)
+ } catch (networkError: Exception) {
+ if (!cache.isFile) throw networkError
+ val cached = cache.readText(Charsets.UTF_8)
+ val releases = parseCatalog(cached, source, abi)
+ GadgetCatalog(releases, true, JSONArray(cached).length() == CATALOG_PAGE_SIZE)
+ }
+ }
+
+ fun download(release: GadgetRelease): InstalledGadget {
+ val currentAbi = detectedAbi() ?: throw IOException("This device ABI is not supported")
+ if (release.abi != currentAbi) throw IOException("The selected release does not match this device ABI")
+
+ val directory = installDirectory(release.source, release.version, release.abi).apply {
+ if (!isDirectory && !mkdirs()) throw IOException("Unable to create Gadget storage")
+ }
+ val temporary = File(directory, "$PAYLOAD_NAME.partial")
+ val destination = File(directory, PAYLOAD_NAME)
+ val metadata = File(directory, METADATA_NAME)
+ if (temporary.exists() && !temporary.delete()) throw IOException("Unable to replace partial download")
+
+ val connection = openConnection(release.downloadUrl, "application/octet-stream")
+ try {
+ if (connection.responseCode !in 200..299) {
+ throw IOException("GitHub download failed (HTTP ${connection.responseCode})")
+ }
+ val compressedLength = connection.contentLengthLong
+ if (compressedLength > MAX_COMPRESSED_BYTES) throw IOException("Gadget download is unexpectedly large")
+ val digest = MessageDigest.getInstance("SHA-256")
+ var total = 0L
+ decompressed(connection.inputStream, release.assetName).use { input ->
+ FileOutputStream(temporary).use { output ->
+ val buffer = ByteArray(128 * 1024)
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ total += count
+ if (total > MAX_DECOMPRESSED_BYTES) throw IOException("Expanded Gadget is unexpectedly large")
+ digest.update(buffer, 0, count)
+ output.write(buffer, 0, count)
+ }
+ output.fd.sync()
+ }
+ }
+ if (total == 0L) throw IOException("Downloaded Gadget is empty")
+ validateElf(temporary, release.abi)
+ replace(temporary, destination)
+ if (!destination.setReadable(true, true) || !destination.setWritable(false, false)) {
+ throw IOException("Unable to secure downloaded Gadget")
+ }
+ val sha256 = digest.digest().joinToString("") { "%02x".format(it) }
+ val record = InstalledGadget(
+ release.source, release.version, release.abi, destination, sha256, total
+ )
+ writeMetadata(metadata, record, release.assetName)
+ return record
+ } catch (error: Exception) {
+ temporary.delete()
+ throw error
+ } finally {
+ connection.disconnect()
+ }
+ }
+
+ fun installed(): List {
+ val abi = detectedAbi() ?: return emptyList()
+ if (!root.isDirectory) return emptyList()
+ return root.walkTopDown()
+ .filter { it.isFile && it.name == METADATA_NAME }
+ .mapNotNull { metadata -> runCatching { readMetadata(metadata) }.getOrNull() }
+ .filter { it.abi == abi && it.file.isFile }
+ .filter { runCatching { validateElf(it.file, it.abi) }.isSuccess }
+ .sortedWith(compareByDescending { versionParts(it.version) }
+ .thenBy { it.source.ordinal })
+ .toList()
+ }
+
+ fun selected(): InstalledGadget? {
+ val path = InstrumentationSettings.getSelectedGadgetPath() ?: return null
+ val selected = runCatching { File(path).canonicalFile }.getOrNull() ?: return null
+ return installed().firstOrNull { runCatching { it.file.canonicalFile == selected }.getOrDefault(false) }
+ }
+
+ fun select(gadget: InstalledGadget) {
+ val currentAbi = detectedAbi() ?: throw IOException("This device ABI is not supported")
+ if (gadget.abi != currentAbi || !gadget.file.isFile) {
+ throw IOException("The selected Gadget is not available for this device")
+ }
+ validateElf(gadget.file, gadget.abi)
+ if (!InstrumentationSettings.setSelectedGadget(
+ gadget.file.canonicalPath,
+ gadget.source.id,
+ gadget.version,
+ gadget.abi.androidName,
+ gadget.sha256
+ )) {
+ throw IOException("Unable to save the selected Gadget")
+ }
+ }
+
+ fun findInstalled(release: GadgetRelease): InstalledGadget? {
+ val expected = runCatching {
+ File(installDirectory(release.source, release.version, release.abi), METADATA_NAME)
+ }.getOrNull() ?: return null
+ return runCatching { readMetadata(expected) }.getOrNull()
+ ?.takeIf { it.file.isFile && runCatching { validateElf(it.file, it.abi) }.isSuccess }
+ }
+
+ private fun parseCatalog(json: String, source: GadgetSource, abi: GadgetAbi): List {
+ val releases = JSONArray(json)
+ val result = ArrayList()
+ for (index in 0 until releases.length()) {
+ val release = releases.optJSONObject(index) ?: continue
+ if (release.optBoolean("draft") || release.optBoolean("prerelease")) continue
+ val version = release.optString("tag_name").trim()
+ if (!isSafeSegment(version)) continue
+ val expected = source.expectedAsset(version, abi.releaseName)
+ val assets = release.optJSONArray("assets") ?: continue
+ for (assetIndex in 0 until assets.length()) {
+ val asset = assets.optJSONObject(assetIndex) ?: continue
+ val name = asset.optString("name")
+ if (name != expected) continue
+ val url = asset.optString("browser_download_url")
+ if (!isTrustedUrl(url)) continue
+ result += GadgetRelease(
+ source = source,
+ version = version,
+ abi = abi,
+ assetName = name,
+ downloadUrl = url,
+ compressedSize = asset.optLong("size", -1L),
+ publishedAt = release.optString("published_at")
+ )
+ break
+ }
+ }
+ return result
+ }
+
+ private fun installDirectory(source: GadgetSource, version: String, abi: GadgetAbi): File {
+ if (!isSafeSegment(version)) throw IOException("Invalid Gadget version")
+ return File(root, "${source.id}${File.separator}$version${File.separator}${abi.androidName}")
+ }
+
+ private fun writeMetadata(file: File, gadget: InstalledGadget, assetName: String) {
+ val json = JSONObject()
+ .put("source", gadget.source.id)
+ .put("version", gadget.version)
+ .put("abi", gadget.abi.androidName)
+ .put("releaseAbi", gadget.abi.releaseName)
+ .put("sha256", gadget.sha256)
+ .put("size", gadget.size)
+ .put("asset", assetName)
+ .toString()
+ writeAtomically(file, json.toByteArray(Charsets.UTF_8))
+ }
+
+ private fun readMetadata(file: File): InstalledGadget {
+ if (!file.isFile || file.length() > MAX_METADATA_BYTES) throw IOException("Invalid Gadget metadata")
+ val json = JSONObject(file.readText(Charsets.UTF_8))
+ val source = GadgetSource.fromId(json.getString("source")) ?: throw IOException("Unknown Gadget source")
+ val version = json.getString("version")
+ if (!isSafeSegment(version)) throw IOException("Invalid Gadget version")
+ val androidAbi = json.getString("abi")
+ val releaseAbi = json.getString("releaseAbi")
+ val abi = GadgetAbi(androidAbi, releaseAbi)
+ val payload = File(file.parentFile, PAYLOAD_NAME).canonicalFile
+ val canonicalRoot = root.canonicalFile
+ if (!payload.path.startsWith(canonicalRoot.path + File.separator)) throw IOException("Invalid Gadget path")
+ return InstalledGadget(
+ source, version, abi, payload, json.getString("sha256"), json.getLong("size")
+ )
+ }
+
+ private fun requestText(url: String): String {
+ val connection = openConnection(url, "application/vnd.github+json")
+ try {
+ if (connection.responseCode !in 200..299) {
+ throw IOException("GitHub release lookup failed (HTTP ${connection.responseCode})")
+ }
+ val bytes = readLimited(connection.inputStream, MAX_CATALOG_BYTES)
+ return bytes.toString(Charsets.UTF_8)
+ } finally {
+ connection.disconnect()
+ }
+ }
+
+ private fun openConnection(value: String, accept: String): HttpURLConnection {
+ if (!isTrustedUrl(value)) throw IOException("Untrusted Gadget download URL")
+ return (URL(value).openConnection() as HttpURLConnection).apply {
+ connectTimeout = 15_000
+ readTimeout = 45_000
+ instanceFollowRedirects = true
+ setRequestProperty("Accept", accept)
+ setRequestProperty("User-Agent", "FridaBox/${BuildConfig.VERSION_NAME}")
+ setRequestProperty("X-GitHub-Api-Version", "2022-11-28")
+ }
+ }
+
+ private fun decompressed(input: InputStream, assetName: String): InputStream = when {
+ assetName.endsWith(".xz") -> XZInputStream(input)
+ assetName.endsWith(".gz") -> GZIPInputStream(input, 128 * 1024)
+ else -> throw IOException("Unsupported Gadget archive format")
+ }
+
+ companion object {
+ private const val DOWNLOAD_ROOT = "fridabox-gadgets"
+ internal const val CATALOG_PAGE_SIZE = 10
+ private const val PAYLOAD_NAME = "payload.so"
+ private const val METADATA_NAME = "metadata.json"
+ private const val MAX_METADATA_BYTES = 16L * 1024L
+ private const val MAX_CATALOG_BYTES = 24L * 1024L * 1024L
+ private const val MAX_COMPRESSED_BYTES = 96L * 1024L * 1024L
+ private const val MAX_DECOMPRESSED_BYTES = 192L * 1024L * 1024L
+
+ internal fun releaseCatalogUrl(source: GadgetSource, page: Int): String {
+ require(page > 0) { "Catalog page must be positive" }
+ return "https://api.github.com/repos/${source.repository}/releases" +
+ "?per_page=$CATALOG_PAGE_SIZE&page=$page"
+ }
+
+ internal fun validateElf(file: File, abi: GadgetAbi) {
+ val header = ByteArray(20)
+ FileInputStream(file).use { input ->
+ if (input.read(header) != header.size) throw IOException("Downloaded Gadget is truncated")
+ }
+ if (header[0].toInt() and 0xff != 0x7f || header[1] != 'E'.code.toByte()
+ || header[2] != 'L'.code.toByte() || header[3] != 'F'.code.toByte()) {
+ throw IOException("Downloaded Gadget is not an ELF library")
+ }
+ if (header[5].toInt() != 1) throw IOException("Only little-endian Android ELF libraries are supported")
+ val machine = (header[18].toInt() and 0xff) or ((header[19].toInt() and 0xff) shl 8)
+ val expected = when (abi.androidName) {
+ "arm64-v8a" -> 183
+ "armeabi-v7a" -> 40
+ "x86" -> 3
+ "x86_64" -> 62
+ else -> -1
+ }
+ if (machine != expected) throw IOException("Gadget ELF architecture does not match ${abi.androidName}")
+ }
+
+ private fun readLimited(input: InputStream, maximum: Long): ByteArray {
+ input.use {
+ val output = ByteArrayOutputStream()
+ val buffer = ByteArray(64 * 1024)
+ var total = 0L
+ while (true) {
+ val count = it.read(buffer)
+ if (count < 0) break
+ total += count
+ if (total > maximum) throw IOException("GitHub response is unexpectedly large")
+ output.write(buffer, 0, count)
+ }
+ return output.toByteArray()
+ }
+ }
+
+ private fun writeAtomically(destination: File, bytes: ByteArray) {
+ destination.parentFile?.let { if (!it.isDirectory && !it.mkdirs()) throw IOException("Unable to create storage") }
+ val temporary = File(destination.parentFile, destination.name + ".partial")
+ FileOutputStream(temporary).use { output ->
+ output.write(bytes)
+ output.fd.sync()
+ }
+ replace(temporary, destination)
+ }
+
+ private fun replace(temporary: File, destination: File) {
+ if (destination.exists() && !destination.delete()) throw IOException("Unable to replace ${destination.name}")
+ if (!temporary.renameTo(destination)) throw IOException("Unable to install ${destination.name}")
+ }
+
+ private fun isSafeSegment(value: String): Boolean =
+ value.isNotBlank() && value.length <= 64 && value.matches(Regex("[A-Za-z0-9._-]+"))
+
+ private fun isTrustedUrl(value: String): Boolean = runCatching {
+ val url = URL(value)
+ val host = url.host.lowercase(Locale.ROOT)
+ url.protocol == "https" && (host == "api.github.com" || host == "github.com"
+ || host.endsWith(".githubusercontent.com"))
+ }.getOrDefault(false)
+
+ private fun versionParts(value: String): String = value.split('.')
+ .joinToString(".") { it.toIntOrNull()?.toString()?.padStart(8, '0') ?: it }
+ }
+}
diff --git a/app/src/main/jniLibs/arm64-v8a/libfrida-gadget.config.so b/app/src/main/jniLibs/arm64-v8a/libfrida-gadget.config.so
deleted file mode 100644
index 6b8b54f..0000000
--- a/app/src/main/jniLibs/arm64-v8a/libfrida-gadget.config.so
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "interaction": {
- "type": "listen",
- "address": "127.0.0.1",
- "port": 27042,
- "on_port_conflict": "pick-next",
- "on_load": "wait"
- },
- "runtime": "qjs",
- "teardown": "minimal"
-}
diff --git a/app/src/main/jniLibs/arm64-v8a/libfrida-gadget.so b/app/src/main/jniLibs/arm64-v8a/libfrida-gadget.so
deleted file mode 100644
index b7829a8..0000000
Binary files a/app/src/main/jniLibs/arm64-v8a/libfrida-gadget.so and /dev/null differ
diff --git a/app/src/main/res/drawable/bg_fridabox_hero.xml b/app/src/main/res/drawable/bg_fridabox_hero.xml
deleted file mode 100644
index 1775d01..0000000
--- a/app/src/main/res/drawable/bg_fridabox_hero.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/bg_glass_navigation.xml b/app/src/main/res/drawable/bg_glass_navigation.xml
new file mode 100644
index 0000000..6bca0b0
--- /dev/null
+++ b/app/src/main/res/drawable/bg_glass_navigation.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_glass_popup.xml b/app/src/main/res/drawable/bg_glass_popup.xml
new file mode 100644
index 0000000..696a476
--- /dev/null
+++ b/app/src/main/res/drawable/bg_glass_popup.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_glass_toolbar.xml b/app/src/main/res/drawable/bg_glass_toolbar.xml
new file mode 100644
index 0000000..bb06e7b
--- /dev/null
+++ b/app/src/main/res/drawable/bg_glass_toolbar.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_launcher_cell.xml b/app/src/main/res/drawable/bg_launcher_cell.xml
new file mode 100644
index 0000000..9b14f51
--- /dev/null
+++ b/app/src/main/res/drawable/bg_launcher_cell.xml
@@ -0,0 +1,17 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_launcher_cell_empty.xml b/app/src/main/res/drawable/bg_launcher_cell_empty.xml
new file mode 100644
index 0000000..e48676e
--- /dev/null
+++ b/app/src/main/res/drawable/bg_launcher_cell_empty.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_liquid_glass_app.xml b/app/src/main/res/drawable/bg_liquid_glass_app.xml
new file mode 100644
index 0000000..3ccd700
--- /dev/null
+++ b/app/src/main/res/drawable/bg_liquid_glass_app.xml
@@ -0,0 +1,36 @@
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fb_add.xml b/app/src/main/res/drawable/ic_fb_add.xml
deleted file mode 100644
index 4da4801..0000000
--- a/app/src/main/res/drawable/ic_fb_add.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_fb_apps.xml b/app/src/main/res/drawable/ic_fb_apps.xml
new file mode 100644
index 0000000..34e1d3d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fb_apps.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fb_clear.xml b/app/src/main/res/drawable/ic_fb_clear.xml
new file mode 100644
index 0000000..618626f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fb_clear.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fb_close.xml b/app/src/main/res/drawable/ic_fb_close.xml
new file mode 100644
index 0000000..0d5c40e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fb_close.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fb_details.xml b/app/src/main/res/drawable/ic_fb_details.xml
new file mode 100644
index 0000000..70902d9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fb_details.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fb_file.xml b/app/src/main/res/drawable/ic_fb_file.xml
new file mode 100644
index 0000000..450042c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fb_file.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fb_gadget.xml b/app/src/main/res/drawable/ic_fb_gadget.xml
new file mode 100644
index 0000000..2be570b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fb_gadget.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fb_import.xml b/app/src/main/res/drawable/ic_fb_import.xml
new file mode 100644
index 0000000..67f7bb9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fb_import.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fb_remove.xml b/app/src/main/res/drawable/ic_fb_remove.xml
new file mode 100644
index 0000000..8f9ee69
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fb_remove.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fb_runtime.xml b/app/src/main/res/drawable/ic_fb_runtime.xml
index 106e440..385c249 100644
--- a/app/src/main/res/drawable/ic_fb_runtime.xml
+++ b/app/src/main/res/drawable/ic_fb_runtime.xml
@@ -1,4 +1,15 @@
-
-
+
+
+
diff --git a/app/src/main/res/drawable/ic_fb_settings.xml b/app/src/main/res/drawable/ic_fb_settings.xml
index 6f5dd16..5f2b92d 100644
--- a/app/src/main/res/drawable/ic_fb_settings.xml
+++ b/app/src/main/res/drawable/ic_fb_settings.xml
@@ -1,4 +1,22 @@
-
-
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fb_workspace.xml b/app/src/main/res/drawable/ic_fb_workspace.xml
index 93292db..43d43dc 100644
--- a/app/src/main/res/drawable/ic_fb_workspace.xml
+++ b/app/src/main/res/drawable/ic_fb_workspace.xml
@@ -1,4 +1,36 @@
-
-
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_fridabox.xml b/app/src/main/res/layout/activity_fridabox.xml
index 86fb6cb..b218721 100644
--- a/app/src/main/res/layout/activity_fridabox.xml
+++ b/app/src/main/res/layout/activity_fridabox.xml
@@ -4,15 +4,18 @@
android:id="@+id/fridabox_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:background="@color/fb_background"
+ android:background="@drawable/bg_liquid_glass_app"
android:fitsSystemWindows="true">
@@ -20,8 +23,9 @@
@@ -31,12 +35,12 @@
android:gravity="center_vertical"
android:layoutDirection="ltr"
android:orientation="horizontal"
- android:paddingStart="18dp"
- android:paddingEnd="18dp">
+ android:paddingStart="14dp"
+ android:paddingEnd="14dp">
@@ -118,29 +122,32 @@
app:layout_constraintTop_toBottomOf="@id/app_bar"
app:trackColor="@color/fb_surface" />
-
diff --git a/app/src/main/res/values/fridabox_colors.xml b/app/src/main/res/values/fridabox_colors.xml
index bad663d..f25c0be 100644
--- a/app/src/main/res/values/fridabox_colors.xml
+++ b/app/src/main/res/values/fridabox_colors.xml
@@ -1,15 +1,21 @@
- #080C14
- #111827
- #182235
- #1D2940
+ #07111D
+ #18263A
+ #22344D
+ #29415D
+ #B31A2940
+ #E0203047
+ #731C3049
+ #66FFFFFF
+ #2EFFFFFF
+ #38FFFFFF
#64E8E8
#1AAFB5
#9B87F5
#F8FAFC
#9CAEC5
- #2B3A52
+ #52708F
#42D39A
#F5BF5B
#FB7185
diff --git a/app/src/main/res/values/fridabox_strings.xml b/app/src/main/res/values/fridabox_strings.xml
index 7b450bd..7b3aa05 100644
--- a/app/src/main/res/values/fridabox_strings.xml
+++ b/app/src/main/res/values/fridabox_strings.xml
@@ -3,16 +3,17 @@
FridaBox
Mobile instrumentation workspace
Workspace
+ Gadgets
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.
+ Import
+ Import from apps
+ Import from file
+ Installed apps
+ Import
+ Imported
+ Loading apps…
+ No importable apps found
On-device
Computer
Clean
@@ -56,4 +57,25 @@
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
+ Gadget manager
+ Active Gadget
+ Not selected
+ Installed
+ Browse versions
+ Close
+ Loading versions…
+ All available versions loaded
+ Showing cached versions
+ Use this Gadget
+ Active
+ Download
+ Download and select a Gadget before using an instrumented launch mode.
+ Gadget %1$s selected
+ Unable to select Gadget
+ Downloading Gadget %1$s…
+ Gadget %1$s downloaded. Choose it from Installed when you want to use it.
+ Gadget %1$s downloaded and selected.
+ Gadget download failed: %1$s
+ Unable to load compatible Gadget releases from GitHub.
+ Unsupported device ABI
diff --git a/app/src/main/res/values/fridabox_styles.xml b/app/src/main/res/values/fridabox_styles.xml
index 0c19a5d..e38ccbc 100644
--- a/app/src/main/res/values/fridabox_styles.xml
+++ b/app/src/main/res/values/fridabox_styles.xml
@@ -11,8 +11,8 @@
- @color/fb_text_primary
- @color/fb_error
- @color/fb_primary
- - @color/fb_background
- - @color/fb_surface
+ - @drawable/bg_liquid_glass_app
+ - @color/fb_background
- @color/fb_background
- false
- false
@@ -22,7 +22,7 @@
diff --git a/app/src/test/java/com/qm4rs/fridabox/ApkInspectorTest.java b/app/src/test/java/com/qm4rs/fridabox/ApkInspectorTest.java
index 30a4a45..c0d7a94 100644
--- a/app/src/test/java/com/qm4rs/fridabox/ApkInspectorTest.java
+++ b/app/src/test/java/com/qm4rs/fridabox/ApkInspectorTest.java
@@ -37,6 +37,15 @@ public class ApkInspectorTest {
assertFalse(result.supported);
}
+ @Test
+ public void splitNativeLibrariesAreInspectedTogether() throws Exception {
+ File base = apkWith("classes.dex");
+ File arm64Split = apkWith("lib/arm64-v8a/libsample.so");
+ ApkInspector.Result result = ApkInspector.inspect(base, arm64Split);
+ assertTrue(result.hasNativeLibraries);
+ assertTrue(result.supported);
+ }
+
private File apkWith(String entry) throws Exception {
File file = temporary.newFile("test-" + System.nanoTime() + ".apk");
try (ZipOutputStream output = new ZipOutputStream(new FileOutputStream(file))) {
diff --git a/app/src/test/java/com/qm4rs/fridabox/GadgetManagerTest.kt b/app/src/test/java/com/qm4rs/fridabox/GadgetManagerTest.kt
new file mode 100644
index 0000000..432d33a
--- /dev/null
+++ b/app/src/test/java/com/qm4rs/fridabox/GadgetManagerTest.kt
@@ -0,0 +1,56 @@
+package com.qm4rs.fridabox
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.IOException
+
+class GadgetManagerTest {
+ @get:Rule
+ val temporary = TemporaryFolder()
+
+ @Test
+ fun releaseAssetNameMatchesOfficialRepository() {
+ assertEquals(
+ "frida-gadget-17.9.11-android-arm64.so.xz",
+ GadgetSource.OFFICIAL.expectedAsset("17.9.11", "arm64")
+ )
+ }
+
+ @Test
+ fun releaseCatalogUsesTenItemPages() {
+ assertEquals(
+ "https://api.github.com/repos/frida/frida/releases?per_page=10&page=3",
+ GadgetManager.releaseCatalogUrl(GadgetSource.OFFICIAL, 3)
+ )
+ }
+
+ @Test
+ fun elfValidationAcceptsMatchingArchitecture() {
+ val file = temporary.newFile("payload.so")
+ file.writeBytes(elfHeader(183))
+ GadgetManager.validateElf(file, GadgetAbi("arm64-v8a", "arm64"))
+ }
+
+ @Test
+ fun elfValidationRejectsWrongArchitecture() {
+ val file = temporary.newFile("payload.so")
+ file.writeBytes(elfHeader(40))
+ assertThrows(IOException::class.java) {
+ GadgetManager.validateElf(file, GadgetAbi("arm64-v8a", "arm64"))
+ }
+ }
+
+ private fun elfHeader(machine: Int): ByteArray = ByteArray(20).also {
+ it[0] = 0x7f
+ it[1] = 'E'.code.toByte()
+ it[2] = 'L'.code.toByte()
+ it[3] = 'F'.code.toByte()
+ it[4] = 2
+ it[5] = 1
+ it[18] = (machine and 0xff).toByte()
+ it[19] = (machine shr 8).toByte()
+ }
+}
diff --git a/docs/BUILDING.md b/docs/BUILDING.md
index cc97388..1603f6d 100644
--- a/docs/BUILDING.md
+++ b/docs/BUILDING.md
@@ -7,23 +7,19 @@
- Android NDK 29.0.13846066 when available. This build host only had the closely
related complete NDK 29.0.14206865, which is recorded in `docs/BASELINE.md`.
- Python 3.10 or newer.
-- Node.js 20 or newer and npm for reproducibly building Frida 17 Java agents.
+- Node.js 20 or newer and npm for reproducibly building Frida 17 Java agents
+ and generating Android vectors from the official `@tabler/icons` package.
-Create `local.properties` with the SDK path, then fetch the pinned official
-Gadget:
-
-```powershell
-python tools\fetch_frida_gadget.py
-```
-
-The script uses the GitHub Releases API and Python's standard `lzma` module. The
-build fails with a direct instruction if the binary is absent. No XZ archive is
-kept.
+Create `local.properties` with the SDK path. Frida Gadget is intentionally not a
+build input and no Gadget binary is packaged in the APK. After installation, the
+user downloads and selects an ABI-compatible Official Frida build from the
+in-app **Gadgets** screen.
Install the pinned Java bridge/compiler and build the controller agents:
```powershell
npm ci
+npm run verify:android-icons
python tools\build_frida_agents.py
```
@@ -31,6 +27,10 @@ Frida 17 no longer bundles runtime bridges into API-loaded GumJS agents. The
compiled files under `scripts/dist/` are reproducible from the source scripts,
`package.json`, and `package-lock.json`.
+UI control icons are generated from pinned `@tabler/icons` sources with
+`npm run generate:android-icons`. The app `check` task runs the matching
+verification script and fails if a committed Android vector is out of sync.
+
On Windows, if the repository path contains spaces, create a no-space junction
and set the Bcore-only native path override:
@@ -52,6 +52,8 @@ 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.
+`verifyDebugApkHasNoGadget` inspects the assembled APK and fails if a Gadget
+library or configuration is accidentally bundled again.
## Production release
diff --git a/docs/TEST_RESULTS.md b/docs/TEST_RESULTS.md
index defad76..1f01642 100644
--- a/docs/TEST_RESULTS.md
+++ b/docs/TEST_RESULTS.md
@@ -1,6 +1,6 @@
# Test results
-Validation date: 2026-07-19
+Validation date: 2026-07-27
## Host validation
@@ -12,10 +12,10 @@ The following commands completed successfully on the build host:
.\gradlew.bat :app:assembleDebug
.\gradlew.bat test
.\gradlew.bat :app:check
-.\gradlew.bat :app:verifyDebugApkFridaPackaging :app:verifyInstrumentationOrdering :app:verifyDemoGuestUnmodified
+.\gradlew.bat :app:verifyDebugApkHasNoGadget :app:verifyInstrumentationOrdering :app:verifyDemoGuestUnmodified
npm ci
python tools\build_frida_agents.py
-python -m py_compile tools\attach_guest.py tools\forward_frida_ports.py tools\fetch_frida_gadget.py tools\build_frida_agents.py
+python -m py_compile tools\attach_guest.py tools\forward_frida_ports.py tools\build_frida_agents.py
```
For this workspace path, native builds used:
@@ -24,8 +24,12 @@ For this workspace path, native builds used:
$env:FRIDABOX_NDK_PROJECT_DIR='D:\FridaBoxBuild\Bcore'
```
-Unit-test results: 16 executions passed, with zero failures, errors, or skips.
-This is eight test methods run for both debug and release variants:
+Unit tests passed with zero failures, including Gadget asset naming, ten-item
+release pagination, ELF ABI validation, and combined base/split APK inspection.
+The original suite also covers:
+
+The app `check` task also verifies that all committed UI control vectors are
+byte-for-byte reproducible from pinned `@tabler/icons` 3.45.0 sources.
- `ApkInspectorTest`: 3 per variant;
- `ApkIntegrityTest`: 1 per variant;
@@ -35,13 +39,9 @@ This is eight test methods run for both debug and release variants:
The final APK and custom verification tasks passed:
- output: `app/build/outputs/apk/debug/FridaBox_4.0.0_arm64-v8a-debug.apk`;
-- size: 19,977,904 bytes;
-- SHA-256: `a87af38ff7f4a54d2cdc0207ac68319a1a05ab8067b7acdeb057c6c967ed2573`;
- packaged ABIs: ARM64 only;
-- packaged native files: `libblackbox.so`, `libfrida-gadget.so`, and
- `libfrida-gadget.config.so` under `lib/arm64-v8a/`;
-- Frida Gadget 17.16.0 SHA-256:
- `6bf149e5d1c5ec701e7b822cab57bb243f1c2a03318fa974fe373ee711a9ed9e`;
+- packaged native files do not include a Frida Gadget binary or
+ Gadget configuration;
- early-load ordering and byte-identical demo-asset checks passed.
- pinned Frida 17 registry/bootstrap/sample agents rebuilt successfully, and
controller help plus empty-range discovery completed successfully.
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 5d67c99..17c95ef 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -5,12 +5,30 @@ Importing an APK does not install that package into Android's real PackageManage
## Import an application
-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.
+1. Open **Workspace** and tap the import icon.
+2. Choose **Import from apps** to clone a launchable app already installed on
+ the device, or **Import from file** to select one base `.apk` through
+ Android's document picker.
+3. FridaBox validates the full installed base/split set or selected file,
+ records its SHA-256 fingerprint, and creates a private guest.
-Split-only packages (`.apks`, `.xapk`, and `.apkm`) and unsupported native ABIs
-are rejected.
+Split-only files (`.apks`, `.xapk`, and `.apkm`) and unsupported native ABIs are
+rejected. Installed split applications are imported through their system package
+so their active base and split APKs remain available to the virtual runtime.
+
+## Install a Gadget
+
+1. Open **Gadgets**. FridaBox detects the device ABI and only offers matching
+ release assets.
+2. Tap **Browse versions**. The minimal release browser loads Official builds
+ from `frida/frida` ten at a time as the list is scrolled.
+3. Download any listed version. Downloads are decompressed in private storage,
+ checked as ELF libraries, and rejected when their architecture does not match.
+4. Select any installed version as active. Downloading another version does not
+ replace the active selection unless no Gadget had been selected yet.
+
+Instrumented launches require an active Gadget. Clean mode remains available
+without one. Gadget binaries are never bundled in the FridaBox APK.
## Choose a launch mode
@@ -34,7 +52,7 @@ 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
+Tap an app icon, 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.
@@ -46,7 +64,7 @@ interceptors, and in-app overlays continue to run in the guest process.
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
+Use **Manage** in an app's launch panel 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
diff --git a/package-lock.json b/package-lock.json
index 4407c35..69a0a5d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,9 +9,21 @@
"frida-java-bridge": "7.0.13"
},
"devDependencies": {
+ "@tabler/icons": "3.45.0",
"frida-compile": "19.0.5"
}
},
+ "node_modules/@tabler/icons": {
+ "version": "3.45.0",
+ "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.45.0.tgz",
+ "integrity": "sha512-jiATwV8+zGYLTZ7gMLGivCic+KtsMZXcDmufIG8umlLxoHhI6902hGYIEt0Oa9Y9SXblNzUlrisHm5jOFMxOQA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/codecalm"
+ }
+ },
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
diff --git a/package.json b/package.json
index 8b5b8e4..4ae172c 100644
--- a/package.json
+++ b/package.json
@@ -1,10 +1,15 @@
{
"name": "fridabox-agents",
"private": true,
+ "scripts": {
+ "generate:android-icons": "node tools/generate_tabler_android_icons.mjs",
+ "verify:android-icons": "node tools/generate_tabler_android_icons.mjs --check"
+ },
"dependencies": {
"frida-java-bridge": "7.0.13"
},
"devDependencies": {
+ "@tabler/icons": "3.45.0",
"frida-compile": "19.0.5"
}
}
diff --git a/tools/fetch_frida_gadget.py b/tools/fetch_frida_gadget.py
deleted file mode 100644
index d0ce85c..0000000
--- a/tools/fetch_frida_gadget.py
+++ /dev/null
@@ -1,85 +0,0 @@
-#!/usr/bin/env python3
-"""Fetch and verify the pinned official Frida Gadget Android ARM64 binary."""
-
-from __future__ import annotations
-
-import argparse
-import hashlib
-import json
-import lzma
-import pathlib
-import shutil
-import tempfile
-import urllib.request
-
-VERSION = "17.16.0"
-ASSET = f"frida-gadget-{VERSION}-android-arm64.so.xz"
-API_URL = f"https://api.github.com/repos/frida/frida/releases/tags/{VERSION}"
-ROOT = pathlib.Path(__file__).resolve().parents[1]
-DEST = ROOT / "app/src/main/jniLibs/arm64-v8a/libfrida-gadget.so"
-CHECKSUM = ROOT / "tools/frida-gadget-17.16.0.sha256"
-EXPECTED_SHA256 = "6bf149e5d1c5ec701e7b822cab57bb243f1c2a03318fa974fe373ee711a9ed9e"
-
-
-def sha256(path: pathlib.Path) -> str:
- digest = hashlib.sha256()
- with path.open("rb") as stream:
- for chunk in iter(lambda: stream.read(1024 * 1024), b""):
- digest.update(chunk)
- return digest.hexdigest()
-
-
-def release_asset_url() -> str:
- request = urllib.request.Request(
- API_URL,
- headers={"Accept": "application/vnd.github+json", "User-Agent": "FridaBox-build"},
- )
- with urllib.request.urlopen(request, timeout=30) as response:
- release = json.load(response)
- if release.get("tag_name") != VERSION:
- raise RuntimeError(f"GitHub returned unexpected release {release.get('tag_name')!r}")
- for asset in release.get("assets", []):
- if asset.get("name") == ASSET:
- return str(asset["browser_download_url"])
- raise RuntimeError(f"Release {VERSION} does not contain {ASSET}")
-
-
-def fetch(force: bool = False) -> str:
- if DEST.exists() and not force:
- digest = sha256(DEST)
- if digest != EXPECTED_SHA256:
- raise RuntimeError(f"Existing Gadget SHA-256 mismatch: {digest}")
- CHECKSUM.write_text(f"{digest} {DEST.name}\n", encoding="ascii")
- return digest
- DEST.parent.mkdir(parents=True, exist_ok=True)
- with tempfile.TemporaryDirectory(prefix="fridabox-") as temp_dir:
- archive = pathlib.Path(temp_dir) / ASSET
- request = urllib.request.Request(release_asset_url(), headers={"User-Agent": "FridaBox-build"})
- with urllib.request.urlopen(request, timeout=120) as response, archive.open("wb") as output:
- shutil.copyfileobj(response, output)
- temporary_output = pathlib.Path(temp_dir) / DEST.name
- with lzma.open(archive, "rb") as source, temporary_output.open("wb") as output:
- shutil.copyfileobj(source, output)
- if temporary_output.stat().st_size < 1_000_000:
- raise RuntimeError("Downloaded Gadget is unexpectedly small")
- shutil.move(str(temporary_output), DEST)
- digest = sha256(DEST)
- if digest != EXPECTED_SHA256:
- DEST.unlink(missing_ok=True)
- raise RuntimeError(f"Downloaded Gadget SHA-256 mismatch: {digest}")
- CHECKSUM.write_text(f"{digest} {DEST.name}\n", encoding="ascii")
- return digest
-
-
-def main() -> int:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--force", action="store_true", help="replace an existing Gadget")
- args = parser.parse_args()
- digest = fetch(args.force)
- print(f"Frida Gadget {VERSION}: {DEST}")
- print(f"SHA-256: {digest}")
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/tools/frida-gadget-17.16.0.sha256 b/tools/frida-gadget-17.16.0.sha256
deleted file mode 100644
index e8b4f04..0000000
--- a/tools/frida-gadget-17.16.0.sha256
+++ /dev/null
@@ -1 +0,0 @@
-6bf149e5d1c5ec701e7b822cab57bb243f1c2a03318fa974fe373ee711a9ed9e libfrida-gadget.so
diff --git a/tools/generate_tabler_android_icons.mjs b/tools/generate_tabler_android_icons.mjs
new file mode 100644
index 0000000..da9fd5a
--- /dev/null
+++ b/tools/generate_tabler_android_icons.mjs
@@ -0,0 +1,69 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+
+const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const packageRoot = path.join(projectRoot, 'node_modules', '@tabler', 'icons');
+const drawableRoot = path.join(projectRoot, 'app', 'src', 'main', 'res', 'drawable');
+const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
+const checkOnly = process.argv.includes('--check');
+
+const icons = new Map([
+ ['layout-grid', 'ic_fb_workspace.xml'],
+ ['package', 'ic_fb_gadget.xml'],
+ ['activity', 'ic_fb_runtime.xml'],
+ ['settings', 'ic_fb_settings.xml'],
+ ['file-import', 'ic_fb_import.xml'],
+ ['apps', 'ic_fb_apps.xml'],
+ ['file', 'ic_fb_file.xml'],
+ ['x', 'ic_fb_close.xml'],
+ ['info-circle', 'ic_fb_details.xml'],
+ ['database-off', 'ic_fb_clear.xml'],
+ ['trash', 'ic_fb_remove.xml'],
+]);
+
+let mismatches = 0;
+for (const [iconName, outputName] of icons) {
+ const sourcePath = path.join(packageRoot, 'icons', 'outline', `${iconName}.svg`);
+ const svg = fs.readFileSync(sourcePath, 'utf8');
+ const paths = [...svg.matchAll(//g)].map((match) => match[1]);
+ if (paths.length === 0) throw new Error(`No SVG paths found for ${iconName}`);
+
+ const pathXml = paths.map((pathData) => [
+ ' `,
+ ].join('\n')).join('\n');
+
+ const output = [
+ '',
+ ``,
+ '',
+ pathXml,
+ '',
+ '',
+ ].join('\n');
+ const outputPath = path.join(drawableRoot, outputName);
+
+ if (checkOnly) {
+ const current = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : '';
+ if (current.replaceAll('\r\n', '\n') !== output) {
+ console.error(`${outputName} is not synchronized with @tabler/icons`);
+ mismatches += 1;
+ }
+ } else {
+ fs.writeFileSync(outputPath, output, 'utf8');
+ }
+}
+
+if (mismatches > 0) process.exitCode = 1;
+else console.log(`${checkOnly ? 'Verified' : 'Generated'} ${icons.size} Android vectors from @tabler/icons ${packageJson.version}`);