diff --git a/configapp/src/main/java/com/jiqiu/configapp/AppListFragment.java b/configapp/src/main/java/com/jiqiu/configapp/AppListFragment.java index e50778c..99e753b 100644 --- a/configapp/src/main/java/com/jiqiu/configapp/AppListFragment.java +++ b/configapp/src/main/java/com/jiqiu/configapp/AppListFragment.java @@ -140,6 +140,8 @@ public class AppListFragment extends Fragment implements AppListAdapter.OnAppTog RadioButton radioStandardInjection = dialogView.findViewById(R.id.radioStandardInjection); RadioButton radioRiruInjection = dialogView.findViewById(R.id.radioRiruInjection); RadioButton radioCustomLinkerInjection = dialogView.findViewById(R.id.radioCustomLinkerInjection); + CheckBox checkboxEnableGadget = dialogView.findViewById(R.id.checkboxEnableGadget); + com.google.android.material.button.MaterialButton btnConfigureGadget = dialogView.findViewById(R.id.btnConfigureGadget); appIcon.setImageDrawable(appInfo.getAppIcon()); appName.setText(appInfo.getAppName()); @@ -155,6 +157,33 @@ public class AppListFragment extends Fragment implements AppListAdapter.OnAppTog radioStandardInjection.setChecked(true); } + // Load gadget config + ConfigManager.GadgetConfig gadgetConfig = configManager.getAppGadgetConfig(appInfo.getPackageName()); + checkboxEnableGadget.setChecked(gadgetConfig != null); + btnConfigureGadget.setEnabled(gadgetConfig != null); + + // Setup gadget listeners + checkboxEnableGadget.setOnCheckedChangeListener((buttonView, isChecked) -> { + btnConfigureGadget.setEnabled(isChecked); + if (!isChecked) { + // Remove gadget config when unchecked + configManager.setAppGadgetConfig(appInfo.getPackageName(), null); + } + }); + + btnConfigureGadget.setOnClickListener(v -> { + ConfigManager.GadgetConfig currentConfig = configManager.getAppGadgetConfig(appInfo.getPackageName()); + if (currentConfig == null) { + currentConfig = new ConfigManager.GadgetConfig(); + } + + GadgetConfigDialog gadgetDialog = GadgetConfigDialog.newInstance(currentConfig); + gadgetDialog.setOnGadgetConfigListener(config -> { + configManager.setAppGadgetConfig(appInfo.getPackageName(), config); + }); + gadgetDialog.show(getParentFragmentManager(), "gadget_config"); + }); + // Setup SO list List globalSoFiles = configManager.getAllSoFiles(); List appSoFiles = configManager.getAppSoFiles(appInfo.getPackageName()); @@ -187,6 +216,16 @@ public class AppListFragment extends Fragment implements AppListAdapter.OnAppTog } configManager.setAppInjectionMethod(appInfo.getPackageName(), selectedMethod); + // Save gadget config if enabled + if (checkboxEnableGadget.isChecked()) { + ConfigManager.GadgetConfig currentGadgetConfig = configManager.getAppGadgetConfig(appInfo.getPackageName()); + if (currentGadgetConfig == null) { + // Create default config if not already configured + currentGadgetConfig = new ConfigManager.GadgetConfig(); + configManager.setAppGadgetConfig(appInfo.getPackageName(), currentGadgetConfig); + } + } + // Save SO selection if (soListRecyclerView.getAdapter() != null) { SoSelectionAdapter adapter = (SoSelectionAdapter) soListRecyclerView.getAdapter(); diff --git a/configapp/src/main/java/com/jiqiu/configapp/ConfigManager.java b/configapp/src/main/java/com/jiqiu/configapp/ConfigManager.java index 3d0dc8e..103c434 100644 --- a/configapp/src/main/java/com/jiqiu/configapp/ConfigManager.java +++ b/configapp/src/main/java/com/jiqiu/configapp/ConfigManager.java @@ -253,6 +253,141 @@ public class ConfigManager { saveConfig(); } + public int getInjectionDelay() { + return config.injectionDelay; + } + + public void setInjectionDelay(int delay) { + config.injectionDelay = delay; + saveConfig(); + } + + public GadgetConfig getAppGadgetConfig(String packageName) { + AppConfig appConfig = config.perAppConfig.get(packageName); + if (appConfig == null) { + return null; + } + return appConfig.gadgetConfig; + } + + public void setAppGadgetConfig(String packageName, GadgetConfig gadgetConfig) { + AppConfig appConfig = config.perAppConfig.get(packageName); + if (appConfig == null) { + appConfig = new AppConfig(); + config.perAppConfig.put(packageName, appConfig); + } + + // Remove old gadget from SO list if exists + if (appConfig.gadgetConfig != null) { + String oldGadgetName = appConfig.gadgetConfig.gadgetName; + appConfig.soFiles.removeIf(soFile -> soFile.name.equals(oldGadgetName)); + } + + appConfig.gadgetConfig = gadgetConfig; + + // Add new gadget to SO list if configured + if (gadgetConfig != null) { + // Check if gadget SO file exists in global storage + String gadgetPath = SO_STORAGE_DIR + "/" + gadgetConfig.gadgetName; + Shell.Result checkResult = Shell.cmd("test -f \"" + gadgetPath + "\" && echo 'exists'").exec(); + + if (checkResult.isSuccess() && !checkResult.getOut().isEmpty()) { + // Add gadget as a SO file + SoFile gadgetSoFile = new SoFile(); + gadgetSoFile.name = gadgetConfig.gadgetName; + gadgetSoFile.storedPath = gadgetPath; + gadgetSoFile.originalPath = gadgetPath; + + // Check if already in list + boolean alreadyExists = false; + for (SoFile soFile : appConfig.soFiles) { + if (soFile.name.equals(gadgetSoFile.name)) { + alreadyExists = true; + break; + } + } + + if (!alreadyExists) { + appConfig.soFiles.add(gadgetSoFile); + Log.i(TAG, "Added gadget SO to app's SO list: " + gadgetSoFile.name); + } + } else { + Log.w(TAG, "Gadget SO file not found in storage: " + gadgetPath); + Log.w(TAG, "Please ensure " + gadgetConfig.gadgetName + " is added to SO library"); + } + } + + saveConfig(); + + // If app is enabled, deploy both gadget SO and config file + if (appConfig.enabled) { + if (gadgetConfig != null) { + deployGadgetConfigFile(packageName, gadgetConfig); + } + // Re-deploy all SO files including gadget + deploySoFilesToApp(packageName); + } + } + + private void deployGadgetConfigFile(String packageName, GadgetConfig gadgetConfig) { + try { + // Create gadget config JSON + String configJson; + if ("script".equals(gadgetConfig.mode)) { + configJson = String.format( + "{\n" + + " \"interaction\": {\n" + + " \"type\": \"script\",\n" + + " \"path\": \"%s\"\n" + + " }\n" + + "}", + gadgetConfig.scriptPath + ); + } else { + configJson = String.format( + "{\n" + + " \"interaction\": {\n" + + " \"type\": \"listen\",\n" + + " \"address\": \"%s\",\n" + + " \"port\": %d,\n" + + " \"on_port_conflict\": \"%s\",\n" + + " \"on_load\": \"%s\"\n" + + " }\n" + + "}", + gadgetConfig.address, + gadgetConfig.port, + gadgetConfig.onPortConflict, + gadgetConfig.onLoad + ); + } + + // Write to temp file + String tempFile = context.getCacheDir() + "/" + gadgetConfig.gadgetName + ".config"; + java.io.FileWriter writer = new java.io.FileWriter(tempFile); + writer.write(configJson); + writer.close(); + + // Copy to app's files directory + String filesDir = "/data/data/" + packageName + "/files"; + String gadgetConfigName = gadgetConfig.gadgetName.replace(".so", ".config.so"); + String targetPath = filesDir + "/" + gadgetConfigName; + + Shell.Result copyResult = Shell.cmd("cp " + tempFile + " " + targetPath).exec(); + if (copyResult.isSuccess()) { + // Set permissions + Shell.cmd("chmod 644 " + targetPath).exec(); + Log.i(TAG, "Deployed gadget config to: " + targetPath); + } else { + Log.e(TAG, "Failed to deploy gadget config: " + String.join("\n", copyResult.getErr())); + } + + // Clean up temp file + new java.io.File(tempFile).delete(); + } catch (Exception e) { + Log.e(TAG, "Failed to create gadget config file", e); + } + } + // Copy SO files directly to app's data directory private void deploySoFilesToApp(String packageName) { AppConfig appConfig = config.perAppConfig.get(packageName); @@ -341,6 +476,11 @@ public class ConfigManager { } Log.i(TAG, "Deployment complete for: " + packageName); + + // Deploy gadget config if configured + if (appConfig.gadgetConfig != null) { + deployGadgetConfigFile(packageName, appConfig.gadgetConfig); + } } // Clean up deployed SO files when app is disabled @@ -386,6 +526,23 @@ public class ConfigManager { } } + // Clean up gadget config file if exists + if (appConfig.gadgetConfig != null) { + String gadgetConfigName = appConfig.gadgetConfig.gadgetName.replace(".so", ".config.so"); + String configPath = filesDir + "/" + gadgetConfigName; + + Shell.Result checkConfigResult = Shell.cmd("test -f \"" + configPath + "\" && echo 'exists'").exec(); + if (checkConfigResult.isSuccess() && !checkConfigResult.getOut().isEmpty()) { + Shell.Result deleteResult = Shell.cmd("rm -f \"" + configPath + "\"").exec(); + if (deleteResult.isSuccess()) { + Log.i(TAG, "Deleted gadget config: " + configPath); + } else { + // Try with su -c + Shell.cmd("su -c 'rm -f \"" + configPath + "\"'").exec(); + } + } + } + Log.i(TAG, "Cleanup complete for: " + packageName); } @@ -402,6 +559,7 @@ public class ConfigManager { public static class ModuleConfig { public boolean enabled = true; public boolean hideInjection = false; + public int injectionDelay = 2; // Default 2 seconds public List globalSoFiles = new ArrayList<>(); public Map perAppConfig = new HashMap<>(); } @@ -410,6 +568,7 @@ public class ConfigManager { public boolean enabled = false; public List soFiles = new ArrayList<>(); public String injectionMethod = "standard"; // "standard", "riru" or "custom_linker" + public GadgetConfig gadgetConfig = null; } public static class SoFile { @@ -425,4 +584,17 @@ public class ConfigManager { return false; } } + + public static class GadgetConfig { + public String mode = "server"; // "server" or "script" + // Server mode config + public String address = "0.0.0.0"; + public int port = 27042; + public String onPortConflict = "fail"; + public String onLoad = "wait"; + // Script mode config + public String scriptPath = "/data/local/tmp/script.js"; + // Common config + public String gadgetName = "libgadget.so"; + } } \ No newline at end of file diff --git a/configapp/src/main/java/com/jiqiu/configapp/FileUtils.java b/configapp/src/main/java/com/jiqiu/configapp/FileUtils.java new file mode 100644 index 0000000..d3cc993 --- /dev/null +++ b/configapp/src/main/java/com/jiqiu/configapp/FileUtils.java @@ -0,0 +1,170 @@ +package com.jiqiu.configapp; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.util.Log; + +import com.topjohnwu.superuser.Shell; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class FileUtils { + private static final String TAG = "FileUtils"; + + /** + * Get real file path from URI, handling both file:// and content:// URIs + * @param context Context + * @param uri The URI to resolve + * @return The real file path, or null if unable to resolve + */ + public static String getRealPathFromUri(Context context, Uri uri) { + if (uri == null) return null; + + String scheme = uri.getScheme(); + if (scheme == null) return null; + + // Handle file:// URIs + if ("file".equals(scheme)) { + return uri.getPath(); + } + + // Handle content:// URIs + if ("content".equals(scheme)) { + // For content URIs, we need to copy the file to a temporary location + return copyFileFromContentUri(context, uri); + } + + // Try direct path extraction as fallback + String path = uri.getPath(); + if (path != null) { + // Some file managers return paths like /external_files/... + // Try to resolve these to actual paths + if (path.contains(":")) { + String[] parts = path.split(":"); + if (parts.length == 2) { + String type = parts[0]; + String relativePath = parts[1]; + + // Common storage locations + if (type.endsWith("/primary")) { + return "/storage/emulated/0/" + relativePath; + } else if (type.contains("external")) { + return "/storage/emulated/0/" + relativePath; + } + } + } + + // Remove any file:// prefix + if (path.startsWith("file://")) { + path = path.substring(7); + } + + // Check if the path exists + Shell.Result result = Shell.cmd("test -f \"" + path + "\" && echo 'exists'").exec(); + if (result.isSuccess() && !result.getOut().isEmpty()) { + return path; + } + } + + return null; + } + + /** + * Copy a file from content URI to temporary location + * @param context Context + * @param uri Content URI + * @return Path to copied file, or null on failure + */ + private static String copyFileFromContentUri(Context context, Uri uri) { + ContentResolver resolver = context.getContentResolver(); + String fileName = getFileName(context, uri); + + if (fileName == null || !fileName.endsWith(".so")) { + fileName = "temp_" + System.currentTimeMillis() + ".so"; + } + + // Create temp directory + File tempDir = new File(context.getCacheDir(), "so_temp"); + if (!tempDir.exists()) { + tempDir.mkdirs(); + } + + File tempFile = new File(tempDir, fileName); + + try (InputStream inputStream = resolver.openInputStream(uri); + OutputStream outputStream = new FileOutputStream(tempFile)) { + + if (inputStream == null) { + Log.e(TAG, "Unable to open input stream for URI: " + uri); + return null; + } + + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + + // Make file readable + tempFile.setReadable(true, false); + + // Use root to copy to a more permanent location + String targetPath = "/data/local/tmp/" + fileName; + Shell.Result result = Shell.cmd( + "cp \"" + tempFile.getAbsolutePath() + "\" \"" + targetPath + "\"", + "chmod 644 \"" + targetPath + "\"" + ).exec(); + + // Clean up temp file + tempFile.delete(); + + if (result.isSuccess()) { + return targetPath; + } else { + Log.e(TAG, "Failed to copy file to /data/local/tmp/"); + return null; + } + + } catch (IOException e) { + Log.e(TAG, "Error copying file from content URI", e); + return null; + } + } + + /** + * Get file name from URI + * @param context Context + * @param uri URI to get name from + * @return File name or null + */ + private static String getFileName(Context context, Uri uri) { + String fileName = null; + + if ("content".equals(uri.getScheme())) { + try (Cursor cursor = context.getContentResolver().query( + uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (nameIndex >= 0) { + fileName = cursor.getString(nameIndex); + } + } + } catch (Exception e) { + Log.e(TAG, "Error getting file name from URI", e); + } + } + + if (fileName == null) { + fileName = uri.getLastPathSegment(); + } + + return fileName; + } +} \ No newline at end of file diff --git a/configapp/src/main/java/com/jiqiu/configapp/GadgetConfigDialog.java b/configapp/src/main/java/com/jiqiu/configapp/GadgetConfigDialog.java new file mode 100644 index 0000000..ed56bca --- /dev/null +++ b/configapp/src/main/java/com/jiqiu/configapp/GadgetConfigDialog.java @@ -0,0 +1,614 @@ +package com.jiqiu.configapp; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.EditText; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.LinearLayout; +import android.content.Intent; +import android.net.Uri; +import android.provider.DocumentsContract; +import android.content.ContentResolver; +import android.database.Cursor; +import android.widget.Toast; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.json.JSONException; +import org.json.JSONObject; + +public class GadgetConfigDialog extends DialogFragment { + + // UI elements + private RadioGroup modeRadioGroup; + private RadioButton radioModeServer; + private RadioButton radioModeScript; + private LinearLayout serverModeLayout; + private LinearLayout scriptModeLayout; + private RadioGroup addressRadioGroup; + private RadioButton radioAddressAll; + private RadioButton radioAddressLocal; + private RadioButton radioAddressCustom; + private EditText editCustomAddress; + private EditText editPort; + private RadioGroup portConflictRadioGroup; + private RadioButton radioConflictFail; + private RadioButton radioConflictPickNext; + private RadioGroup onLoadRadioGroup; + private RadioButton radioLoadWait; + private RadioButton radioLoadResume; + private EditText editScriptPath; + private EditText editGadgetName; + private EditText editJsonPreview; + + // Configuration data + private ConfigManager.GadgetConfig config; + private OnGadgetConfigListener listener; + + // Flag to prevent recursive updates + private boolean isUpdatingUI = false; + + // Activity result launchers + private ActivityResultLauncher fileBrowserLauncher; + private ActivityResultLauncher filePickerLauncher; + + public interface OnGadgetConfigListener { + void onGadgetConfigSaved(ConfigManager.GadgetConfig config); + } + + public static GadgetConfigDialog newInstance(ConfigManager.GadgetConfig config) { + GadgetConfigDialog dialog = new GadgetConfigDialog(); + dialog.config = config != null ? config : new ConfigManager.GadgetConfig(); + return dialog; + } + + public void setOnGadgetConfigListener(OnGadgetConfigListener listener) { + this.listener = listener; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Initialize file browser launcher + fileBrowserLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == android.app.Activity.RESULT_OK && result.getData() != null) { + String selectedPath = result.getData().getStringExtra("selected_path"); + if (selectedPath != null) { + editScriptPath.setText(selectedPath); + config.scriptPath = selectedPath; + updateJsonPreview(); + } + } + } + ); + + // Initialize file picker launcher + filePickerLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == android.app.Activity.RESULT_OK && result.getData() != null) { + Uri uri = result.getData().getData(); + if (uri != null) { + String path = getPathFromUri(uri); + if (path != null) { + editScriptPath.setText(path); + config.scriptPath = path; + updateJsonPreview(); + } else { + Toast.makeText(getContext(), "无法获取文件路径", Toast.LENGTH_SHORT).show(); + } + } + } + } + ); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + View view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_gadget_config, null); + + initViews(view); + loadConfig(); + setupListeners(); + updateJsonPreview(); + + return new MaterialAlertDialogBuilder(getContext()) + .setTitle("Gadget 配置") + .setView(view) + .setPositiveButton("保存", (dialog, which) -> saveConfig()) + .setNegativeButton("取消", null) + .create(); + } + + private void initViews(View view) { + modeRadioGroup = view.findViewById(R.id.modeRadioGroup); + radioModeServer = view.findViewById(R.id.radioModeServer); + radioModeScript = view.findViewById(R.id.radioModeScript); + serverModeLayout = view.findViewById(R.id.serverModeLayout); + scriptModeLayout = view.findViewById(R.id.scriptModeLayout); + addressRadioGroup = view.findViewById(R.id.addressRadioGroup); + radioAddressAll = view.findViewById(R.id.radioAddressAll); + radioAddressLocal = view.findViewById(R.id.radioAddressLocal); + radioAddressCustom = view.findViewById(R.id.radioAddressCustom); + editCustomAddress = view.findViewById(R.id.editCustomAddress); + editPort = view.findViewById(R.id.editPort); + portConflictRadioGroup = view.findViewById(R.id.portConflictRadioGroup); + radioConflictFail = view.findViewById(R.id.radioConflictFail); + radioConflictPickNext = view.findViewById(R.id.radioConflictPickNext); + onLoadRadioGroup = view.findViewById(R.id.onLoadRadioGroup); + radioLoadWait = view.findViewById(R.id.radioLoadWait); + radioLoadResume = view.findViewById(R.id.radioLoadResume); + editScriptPath = view.findViewById(R.id.editScriptPath); + editGadgetName = view.findViewById(R.id.editGadgetName); + editJsonPreview = view.findViewById(R.id.editJsonPreview); + + // File select button + View btnSelectScript = view.findViewById(R.id.btnSelectScript); + if (btnSelectScript != null) { + btnSelectScript.setOnClickListener(v -> selectScriptFile()); + } + } + + private void loadConfig() { + isUpdatingUI = true; + + // Load mode + if ("script".equals(config.mode)) { + radioModeScript.setChecked(true); + serverModeLayout.setVisibility(View.GONE); + scriptModeLayout.setVisibility(View.VISIBLE); + } else { + radioModeServer.setChecked(true); + serverModeLayout.setVisibility(View.VISIBLE); + scriptModeLayout.setVisibility(View.GONE); + } + + // Load address + if ("127.0.0.1".equals(config.address)) { + radioAddressLocal.setChecked(true); + } else if ("0.0.0.0".equals(config.address)) { + radioAddressAll.setChecked(true); + } else { + radioAddressCustom.setChecked(true); + editCustomAddress.setText(config.address); + editCustomAddress.setEnabled(true); + } + + // Load port + editPort.setText(String.valueOf(config.port)); + + // Load port conflict handling + if ("pick-next".equals(config.onPortConflict)) { + radioConflictPickNext.setChecked(true); + } else { + radioConflictFail.setChecked(true); + } + + // Load on load handling + if ("resume".equals(config.onLoad)) { + radioLoadResume.setChecked(true); + } else { + radioLoadWait.setChecked(true); + } + + // Load script path + editScriptPath.setText(config.scriptPath); + + // Load gadget name + editGadgetName.setText(config.gadgetName); + + isUpdatingUI = false; + } + + private void setupListeners() { + // Mode radio group listener + modeRadioGroup.setOnCheckedChangeListener((group, checkedId) -> { + if (!isUpdatingUI) { + if (checkedId == R.id.radioModeScript) { + config.mode = "script"; + serverModeLayout.setVisibility(View.GONE); + scriptModeLayout.setVisibility(View.VISIBLE); + } else { + config.mode = "server"; + serverModeLayout.setVisibility(View.VISIBLE); + scriptModeLayout.setVisibility(View.GONE); + } + updateJsonPreview(); + } + }); + + // Address radio group listener + addressRadioGroup.setOnCheckedChangeListener((group, checkedId) -> { + if (!isUpdatingUI) { + if (checkedId == R.id.radioAddressCustom) { + editCustomAddress.setEnabled(true); + editCustomAddress.requestFocus(); + } else { + editCustomAddress.setEnabled(false); + if (checkedId == R.id.radioAddressAll) { + config.address = "0.0.0.0"; + } else if (checkedId == R.id.radioAddressLocal) { + config.address = "127.0.0.1"; + } + updateJsonPreview(); + } + } + }); + + // Custom address text watcher + editCustomAddress.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable s) { + if (!isUpdatingUI && radioAddressCustom.isChecked()) { + config.address = s.toString().trim(); + updateJsonPreview(); + } + } + }); + + // Port text watcher + editPort.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable s) { + if (!isUpdatingUI) { + try { + int port = Integer.parseInt(s.toString()); + if (port >= 1 && port <= 65535) { + config.port = port; + updateJsonPreview(); + } + } catch (NumberFormatException e) { + // Ignore invalid input + } + } + } + }); + + // Port conflict radio group listener + portConflictRadioGroup.setOnCheckedChangeListener((group, checkedId) -> { + if (!isUpdatingUI) { + config.onPortConflict = (checkedId == R.id.radioConflictPickNext) ? "pick-next" : "fail"; + updateJsonPreview(); + } + }); + + // On load radio group listener + onLoadRadioGroup.setOnCheckedChangeListener((group, checkedId) -> { + if (!isUpdatingUI) { + config.onLoad = (checkedId == R.id.radioLoadResume) ? "resume" : "wait"; + updateJsonPreview(); + } + }); + + // Script path text watcher + editScriptPath.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable s) { + if (!isUpdatingUI) { + config.scriptPath = s.toString().trim(); + updateJsonPreview(); + } + } + }); + + // Gadget name text watcher + editGadgetName.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable s) { + if (!isUpdatingUI) { + config.gadgetName = s.toString().trim(); + } + } + }); + + // JSON preview text watcher + editJsonPreview.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable s) { + if (!isUpdatingUI) { + parseJsonAndUpdateUI(s.toString()); + } + } + }); + } + + private void updateJsonPreview() { + if (isUpdatingUI) return; + + try { + JSONObject root = new JSONObject(); + JSONObject interaction = new JSONObject(); + + if ("script".equals(config.mode)) { + interaction.put("type", "script"); + interaction.put("path", config.scriptPath); + } else { + interaction.put("type", "listen"); + interaction.put("address", config.address); + interaction.put("port", config.port); + interaction.put("on_port_conflict", config.onPortConflict); + interaction.put("on_load", config.onLoad); + } + + root.put("interaction", interaction); + + isUpdatingUI = true; + editJsonPreview.setText(root.toString(2)); + isUpdatingUI = false; + } catch (JSONException e) { + // Should not happen + e.printStackTrace(); + } + } + + private void parseJsonAndUpdateUI(String json) { + try { + JSONObject root = new JSONObject(json); + JSONObject interaction = root.getJSONObject("interaction"); + + isUpdatingUI = true; + + // Update mode + String type = interaction.getString("type"); + if ("script".equals(type)) { + config.mode = "script"; + radioModeScript.setChecked(true); + serverModeLayout.setVisibility(View.GONE); + scriptModeLayout.setVisibility(View.VISIBLE); + + // Update script path + if (interaction.has("path")) { + config.scriptPath = interaction.getString("path"); + editScriptPath.setText(config.scriptPath); + } + } else { + config.mode = "server"; + radioModeServer.setChecked(true); + serverModeLayout.setVisibility(View.VISIBLE); + scriptModeLayout.setVisibility(View.GONE); + + // Update address + String address = interaction.getString("address"); + config.address = address; + if ("0.0.0.0".equals(address)) { + radioAddressAll.setChecked(true); + editCustomAddress.setEnabled(false); + } else if ("127.0.0.1".equals(address)) { + radioAddressLocal.setChecked(true); + editCustomAddress.setEnabled(false); + } else { + radioAddressCustom.setChecked(true); + editCustomAddress.setText(address); + editCustomAddress.setEnabled(true); + } + + // Update port + config.port = interaction.getInt("port"); + editPort.setText(String.valueOf(config.port)); + + // Update port conflict + String onPortConflict = interaction.getString("on_port_conflict"); + config.onPortConflict = onPortConflict; + if ("pick-next".equals(onPortConflict)) { + radioConflictPickNext.setChecked(true); + } else { + radioConflictFail.setChecked(true); + } + + // Update on load + String onLoad = interaction.getString("on_load"); + config.onLoad = onLoad; + if ("resume".equals(onLoad)) { + radioLoadResume.setChecked(true); + } else { + radioLoadWait.setChecked(true); + } + } + + isUpdatingUI = false; + } catch (JSONException e) { + // Invalid JSON, ignore + } + } + + private void saveConfig() { + if (listener != null) { + // Ensure gadget name is not empty + if (config.gadgetName == null || config.gadgetName.trim().isEmpty()) { + config.gadgetName = "libgadget.so"; + } + listener.onGadgetConfigSaved(config); + } + } + + private void selectScriptFile() { + String[] options = {"浏览文件系统", "从外部文件管理器选择", "手动输入路径"}; + + new MaterialAlertDialogBuilder(requireContext()) + .setTitle("选择 Script 文件") + .setItems(options, (dialog, which) -> { + if (which == 0) { + openFileBrowser(); + } else if (which == 1) { + openFilePicker(); + } else { + showPathInputDialog(); + } + }) + .show(); + } + + private void openFileBrowser() { + // Show path selection dialog first + String[] paths = { + "/data/local/tmp", + "/sdcard", + "/sdcard/Download", + "/storage/emulated/0", + "自定义路径..." + }; + + new MaterialAlertDialogBuilder(requireContext()) + .setTitle("选择起始目录") + .setItems(paths, (dialog, which) -> { + if (which == paths.length - 1) { + // Custom path + showCustomPathDialog(); + } else { + Intent intent = new Intent(getContext(), FileBrowserActivity.class); + intent.putExtra(FileBrowserActivity.EXTRA_START_PATH, paths[which]); + intent.putExtra(FileBrowserActivity.EXTRA_FILE_FILTER, ".js"); + fileBrowserLauncher.launch(intent); + } + }) + .show(); + } + + private void showCustomPathDialog() { + View view = getLayoutInflater().inflate(R.layout.dialog_input, null); + android.widget.EditText editText = view.findViewById(android.R.id.edit); + editText.setText("/"); + editText.setHint("输入起始路径"); + + new MaterialAlertDialogBuilder(requireContext()) + .setTitle("自定义起始路径") + .setView(view) + .setPositiveButton("确定", (dialog, which) -> { + String path = editText.getText().toString().trim(); + if (!path.isEmpty()) { + Intent intent = new Intent(getContext(), FileBrowserActivity.class); + intent.putExtra(FileBrowserActivity.EXTRA_START_PATH, path); + intent.putExtra(FileBrowserActivity.EXTRA_FILE_FILTER, ".js"); + fileBrowserLauncher.launch(intent); + } + }) + .setNegativeButton("取消", null) + .show(); + } + + private void openFilePicker() { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("*/*"); + intent.addCategory(Intent.CATEGORY_OPENABLE); + // Add MIME types that might help filter JS files + String[] mimeTypes = {"text/javascript", "application/javascript", "text/plain", "*/*"}; + intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); + // Suggest starting location + intent.putExtra("android.provider.extra.INITIAL_URI", + android.net.Uri.parse("content://com.android.externalstorage.documents/document/primary%3ADownload")); + filePickerLauncher.launch(intent); + } + + private void showPathInputDialog() { + View view = getLayoutInflater().inflate(R.layout.dialog_input, null); + android.widget.EditText editText = view.findViewById(android.R.id.edit); + editText.setText("/data/local/tmp/"); + editText.setHint("/data/local/tmp/script.js"); + + new MaterialAlertDialogBuilder(requireContext()) + .setTitle("输入 Script 文件路径") + .setView(view) + .setPositiveButton("确定", (dialog, which) -> { + String path = editText.getText().toString().trim(); + if (!path.isEmpty()) { + editScriptPath.setText(path); + config.scriptPath = path; + updateJsonPreview(); + } + }) + .setNegativeButton("取消", null) + .show(); + } + + private String getPathFromUri(Uri uri) { + String path = null; + + // Try to get path from MediaStore + if ("content".equals(uri.getScheme())) { + try { + ContentResolver resolver = getContext().getContentResolver(); + try (Cursor cursor = resolver.query(uri, new String[]{"_data"}, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int columnIndex = cursor.getColumnIndex("_data"); + if (columnIndex != -1) { + path = cursor.getString(columnIndex); + } + } + } + } catch (Exception e) { + // Ignore + } + + // Try DocumentsContract if MediaStore fails + if (path == null && DocumentsContract.isDocumentUri(getContext(), uri)) { + try { + String docId = DocumentsContract.getDocumentId(uri); + if (uri.getAuthority().equals("com.android.externalstorage.documents")) { + String[] split = docId.split(":"); + if (split.length >= 2) { + String type = split[0]; + if ("primary".equalsIgnoreCase(type)) { + path = "/storage/emulated/0/" + split[1]; + } else { + path = "/storage/" + type + "/" + split[1]; + } + } + } + } catch (Exception e) { + // Ignore + } + } + } else if ("file".equals(uri.getScheme())) { + path = uri.getPath(); + } + + return path; + } +} \ No newline at end of file diff --git a/configapp/src/main/java/com/jiqiu/configapp/SettingsFragment.java b/configapp/src/main/java/com/jiqiu/configapp/SettingsFragment.java index c8bedf5..310fb29 100644 --- a/configapp/src/main/java/com/jiqiu/configapp/SettingsFragment.java +++ b/configapp/src/main/java/com/jiqiu/configapp/SettingsFragment.java @@ -8,6 +8,9 @@ import android.view.View; import android.view.ViewGroup; import android.widget.RadioButton; import android.widget.RadioGroup; +import android.widget.EditText; +import android.text.TextWatcher; +import android.text.Editable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -24,6 +27,8 @@ public class SettingsFragment extends Fragment { private RadioGroup radioGroupFilter; private RadioButton radioShowAll; private RadioButton radioHideSystem; + private EditText editInjectionDelay; + private ConfigManager configManager; private SharedPreferences sharedPreferences; private OnSettingsChangeListener settingsChangeListener; @@ -53,6 +58,9 @@ public class SettingsFragment extends Fragment { radioGroupFilter = view.findViewById(R.id.radio_group_filter); radioShowAll = view.findViewById(R.id.radio_show_all); radioHideSystem = view.findViewById(R.id.radio_hide_system); + editInjectionDelay = view.findViewById(R.id.editInjectionDelay); + + configManager = new ConfigManager(getContext()); } private void initSharedPreferences() { @@ -67,6 +75,10 @@ public class SettingsFragment extends Fragment { } else { radioShowAll.setChecked(true); } + + // Load injection delay + int injectionDelay = configManager.getInjectionDelay(); + editInjectionDelay.setText(String.valueOf(injectionDelay)); } private void setupListeners() { @@ -86,6 +98,32 @@ public class SettingsFragment extends Fragment { } } }); + + // Injection delay listener + editInjectionDelay.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable s) { + String text = s.toString().trim(); + if (!text.isEmpty()) { + try { + int delay = Integer.parseInt(text); + // Limit delay between 0 and 60 seconds + if (delay < 0) delay = 0; + if (delay > 60) delay = 60; + + configManager.setInjectionDelay(delay); + } catch (NumberFormatException e) { + // Ignore invalid input + } + } + } + }); } public void setOnSettingsChangeListener(OnSettingsChangeListener listener) { diff --git a/configapp/src/main/java/com/jiqiu/configapp/SoManagerFragment.java b/configapp/src/main/java/com/jiqiu/configapp/SoManagerFragment.java index 1f4ea15..2f13782 100644 --- a/configapp/src/main/java/com/jiqiu/configapp/SoManagerFragment.java +++ b/configapp/src/main/java/com/jiqiu/configapp/SoManagerFragment.java @@ -196,6 +196,12 @@ public class SoManagerFragment extends Fragment { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.setType("*/*"); intent.addCategory(Intent.CATEGORY_OPENABLE); + // Add MIME types that might help filter SO files + String[] mimeTypes = {"application/octet-stream", "application/x-sharedlib", "*/*"}; + intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); + // Suggest starting location + intent.putExtra("android.provider.extra.INITIAL_URI", + android.net.Uri.parse("content://com.android.externalstorage.documents/document/primary%3ADownload")); filePickerLauncher.launch(intent); } @@ -219,14 +225,12 @@ public class SoManagerFragment extends Fragment { } private void handleFileSelection(Uri uri) { - // Get real path from URI - String path = uri.getPath(); + // Get real path from URI using proper URI handling + String path = FileUtils.getRealPathFromUri(requireContext(), uri); if (path != null) { - // Remove the file:// prefix if present - if (path.startsWith("file://")) { - path = path.substring(7); - } showDeleteOriginalDialog(path); + } else { + Toast.makeText(getContext(), "无法获取文件路径,请尝试其他方式", Toast.LENGTH_SHORT).show(); } } diff --git a/configapp/src/main/res/layout/dialog_app_config.xml b/configapp/src/main/res/layout/dialog_app_config.xml index af1eb05..5b867bd 100644 --- a/configapp/src/main/res/layout/dialog_app_config.xml +++ b/configapp/src/main/res/layout/dialog_app_config.xml @@ -1,10 +1,14 @@ - + android:layout_height="wrap_content"> + + + + + + + + + + + + + + + + - \ No newline at end of file + \ No newline at end of file diff --git a/configapp/src/main/res/layout/dialog_gadget_config.xml b/configapp/src/main/res/layout/dialog_gadget_config.xml new file mode 100644 index 0000000..5cd2738 --- /dev/null +++ b/configapp/src/main/res/layout/dialog_gadget_config.xml @@ -0,0 +1,299 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/configapp/src/main/res/layout/fragment_settings.xml b/configapp/src/main/res/layout/fragment_settings.xml index 3270a17..953b6cc 100644 --- a/configapp/src/main/res/layout/fragment_settings.xml +++ b/configapp/src/main/res/layout/fragment_settings.xml @@ -72,6 +72,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + address = address; + + std::string portStr = extractValue(gadgetObj, "port"); + if (!portStr.empty()) gadgetConfig->port = std::stoi(portStr); + + std::string onPortConflict = extractValue(gadgetObj, "onPortConflict"); + if (!onPortConflict.empty()) gadgetConfig->onPortConflict = onPortConflict; + + std::string onLoad = extractValue(gadgetObj, "onLoad"); + if (!onLoad.empty()) gadgetConfig->onLoad = onLoad; + + std::string gadgetName = extractValue(gadgetObj, "gadgetName"); + if (!gadgetName.empty()) gadgetConfig->gadgetName = gadgetName; + + appConfig.gadgetConfig = gadgetConfig; + LOGD("Loaded gadget config: %s:%d, name: %s", + gadgetConfig->address.c_str(), gadgetConfig->port, gadgetConfig->gadgetName.c_str()); + } + } + g_config.perAppConfig[packageName] = appConfig; const char* methodName = appConfig.injectionMethod == InjectionMethod::CUSTOM_LINKER ? "custom_linker" : appConfig.injectionMethod == InjectionMethod::RIRU ? "riru" : "standard"; - LOGD("Loaded config for app: %s, enabled: %d, method: %s, SO files: %zu", - packageName.c_str(), appConfig.enabled, methodName, appConfig.soFiles.size()); + LOGD("Loaded config for app: %s, enabled: %d, method: %s, SO files: %zu, gadget: %s", + packageName.c_str(), appConfig.enabled, methodName, appConfig.soFiles.size(), + appConfig.gadgetConfig ? "yes" : "no"); } ModuleConfig readConfig() { @@ -118,7 +158,13 @@ namespace Config { std::string hideStr = extractValue(json, "hideInjection"); g_config.hideInjection = (hideStr == "true"); - LOGD("Module enabled: %d, hide injection: %d", g_config.enabled, g_config.hideInjection); + std::string delayStr = extractValue(json, "injectionDelay"); + if (!delayStr.empty()) { + g_config.injectionDelay = std::stoi(delayStr); + } + + LOGD("Module enabled: %d, hide injection: %d, injection delay: %d", + g_config.enabled, g_config.hideInjection, g_config.injectionDelay); // Parse perAppConfig size_t perAppPos = json.find("\"perAppConfig\""); @@ -212,4 +258,11 @@ namespace Config { } return InjectionMethod::STANDARD; } + + int getInjectionDelay() { + if (!g_configLoaded) { + readConfig(); + } + return g_config.injectionDelay; + } } \ No newline at end of file diff --git a/module/src/main/cpp/config.h b/module/src/main/cpp/config.h index 86b4583..289e400 100644 --- a/module/src/main/cpp/config.h +++ b/module/src/main/cpp/config.h @@ -19,15 +19,25 @@ namespace Config { CUSTOM_LINKER = 2 }; + struct GadgetConfig { + std::string address = "0.0.0.0"; + int port = 27042; + std::string onPortConflict = "fail"; + std::string onLoad = "wait"; + std::string gadgetName = "libgadget.so"; + }; + struct AppConfig { bool enabled = false; InjectionMethod injectionMethod = InjectionMethod::STANDARD; std::vector soFiles; + GadgetConfig* gadgetConfig = nullptr; }; struct ModuleConfig { bool enabled = true; bool hideInjection = false; + int injectionDelay = 2; // Default 2 seconds std::unordered_map perAppConfig; }; @@ -45,6 +55,9 @@ namespace Config { // Get injection method for specific app InjectionMethod getAppInjectionMethod(const std::string& packageName); + + // Get injection delay in seconds + int getInjectionDelay(); } #endif // CONFIG_H \ No newline at end of file diff --git a/module/src/main/cpp/hack_new.cpp b/module/src/main/cpp/hack_new.cpp index 1860eab..07ecf2f 100644 --- a/module/src/main/cpp/hack_new.cpp +++ b/module/src/main/cpp/hack_new.cpp @@ -88,8 +88,12 @@ void load_so_file_custom_linker(const char *game_data_dir, const Config::SoFile void hack_thread_func(const char *game_data_dir, const char *package_name, JavaVM *vm) { LOGI("Hack thread started for package: %s", package_name); - // Wait a bit for app to initialize and files to be copied - sleep(2); + // Get injection delay from config + int delay = Config::getInjectionDelay(); + LOGI("Waiting %d seconds before injection", delay); + + // Wait for app to initialize and files to be copied + sleep(delay); // Get injection method for this app Config::InjectionMethod method = Config::getAppInjectionMethod(package_name); @@ -103,6 +107,12 @@ void hack_thread_func(const char *game_data_dir, const char *package_name, JavaV // Load each SO file using the configured method for (const auto &soFile : soFiles) { + // Skip config files + if (soFile.name.find(".config.so") != std::string::npos) { + LOGI("Skipping config file: %s", soFile.name.c_str()); + continue; + } + LOGI("Loading SO: %s (stored as: %s)", soFile.name.c_str(), soFile.storedPath.c_str()); if (method == Config::InjectionMethod::CUSTOM_LINKER) {