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}`);