diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 33b91aa..b268ef3 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -5,9 +5,6 @@ - - \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c11b271..5d452b6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,8 @@ - + + diff --git a/app/src/main/java/com/example/studyapp/MainActivity.java b/app/src/main/java/com/example/studyapp/MainActivity.java index 5571d86..ad4cbcf 100644 --- a/app/src/main/java/com/example/studyapp/MainActivity.java +++ b/app/src/main/java/com/example/studyapp/MainActivity.java @@ -21,9 +21,12 @@ import androidx.appcompat.app.AppCompatActivity; import com.example.studyapp.request.ScriptResultRequest; import com.example.studyapp.service.CloudPhoneManageService; +import com.example.studyapp.socks.SingBoxLauncher; +import com.example.studyapp.utils.IpUtil; import com.example.studyapp.utils.ShellUtils; import java.io.File; +import java.lang.ref.WeakReference; import retrofit2.Call; import retrofit2.Callback; @@ -43,23 +46,6 @@ public class MainActivity extends AppCompatActivity { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); - vpnRequestLauncher = registerForActivityResult( - new ActivityResultContracts.StartActivityForResult(), - result -> { - if (result.getResultCode() == RESULT_OK) { - startProxyVpnService(); - } else { - Toast.makeText(this, "VPN setup failed", Toast.LENGTH_SHORT).show(); - new AlertDialog.Builder(this) - .setTitle("VPN 配置失败") - .setMessage("未能成功配置 VPN。要重试吗?") - .setPositiveButton("重试", (dialog, which) -> startProxyVpn(this)) - .setNegativeButton("取消", null) - .show(); - } - } - ); - // 检查存储权限 if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { @@ -68,7 +54,6 @@ public class MainActivity extends AppCompatActivity { REQUEST_CODE_STORAGE_PERMISSION); } - startProxyVpn(this); // 查找按钮对象 Button runScriptButton = findViewById(R.id.run_script_button); @@ -79,18 +64,20 @@ public class MainActivity extends AppCompatActivity { } } - private void startProxyVpn(Context context) { // 避免强制依赖 MainActivity - Intent intent = VpnService.prepare(context); - if (intent != null) { - vpnRequestLauncher.launch(intent); - } else { - startProxyVpnService(); - } - } - - private void startProxyVpnService() { - Intent serviceIntent = new Intent(this, ProxyVpnService.class); - startService(serviceIntent); + private void startProxyVpn(Context context) { + WeakReference contextRef = new WeakReference<>(context); + new Thread(() -> { + try { + SingBoxLauncher.getInstance().start(context, IpUtil.safeClientIp()); + } catch (Exception e) { + Context ctx = contextRef.get(); + if (ctx != null) { + runOnUiThread(() -> + Toast.makeText(ctx, "Failed to start VPN: " + e.getMessage(), Toast.LENGTH_SHORT).show() + ); + } + } + }).start(); } @Override @@ -98,8 +85,8 @@ public class MainActivity extends AppCompatActivity { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == REQUEST_CODE_STORAGE_PERMISSION) { if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - permissionHandler(); Toast.makeText(this, "Permissions granted", Toast.LENGTH_SHORT).show(); + startProxyVpn(this); } else { Toast.makeText(this, "Permission denied", Toast.LENGTH_SHORT).show(); // 可选择终止操作或退出程序 @@ -219,23 +206,4 @@ public class MainActivity extends AppCompatActivity { } }); } - - private void grantPermission(String name) - { - ShellUtils.execRootCmd("pm grant " + this.getPackageName() + " " + name); - } - private void appopsAllow(String name) - { - ShellUtils.execRootCmd("appops set " + this.getPackageName() + " " + name + " allow"); - } - private void permissionHandler() { - grantPermission("android.permission.SYSTEM_ALERT_WINDOW"); - grantPermission("android.permission.FOREGROUND_SERVICE"); - grantPermission("android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"); - grantPermission("android.permission.WRITE_SECURE_SETTINGS"); - grantPermission("android.permission.READ_EXTERNAL_STORAGE"); - grantPermission("android.permission.WRITE_EXTERNAL_STORAGE"); - appopsAllow("MANAGE_EXTERNAL_STORAGE"); - } - } \ No newline at end of file diff --git a/app/src/main/java/com/example/studyapp/ProxyVpnService.java b/app/src/main/java/com/example/studyapp/ProxyVpnService.java deleted file mode 100644 index 02b8573..0000000 --- a/app/src/main/java/com/example/studyapp/ProxyVpnService.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.example.studyapp; - -import android.content.Intent; -import android.net.VpnService; -import android.os.ParcelFileDescriptor; - -import java.io.IOException; - -public class ProxyVpnService extends VpnService { - private ParcelFileDescriptor vpnInterface; - - @Override - public void onCreate() { - super.onCreate(); - Builder builder = new Builder(); - try { - builder.addAddress("10.0.2.15", 24); // 配置虛擬 IP - builder.addRoute("0.0.0.0", 0); // 配置攔截所有流量的路由 - builder.setSession("Proxy VPN Service"); - builder.addDnsServer("8.8.8.8"); // 設置 DNS - vpnInterface = builder.establish(); // 啟動 VPN 通道,保存接口描述符 - } catch (Exception e) { - e.printStackTrace(); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - if (vpnInterface != null) { - try { - vpnInterface.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - // 在实际实现中可能需要处理 Intent 数据 - return START_STICKY; - } -} diff --git a/app/src/main/java/com/example/studyapp/socks/SingBoxLauncher.java b/app/src/main/java/com/example/studyapp/socks/SingBoxLauncher.java new file mode 100644 index 0000000..cf8c66a --- /dev/null +++ b/app/src/main/java/com/example/studyapp/socks/SingBoxLauncher.java @@ -0,0 +1,304 @@ +package com.example.studyapp.socks; + +import android.content.Context; +import android.content.Intent; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; + +import com.example.studyapp.utils.ShellUtils; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +public class SingBoxLauncher { + private static SingBoxLauncher instance; + private static final String PKG = "io.nekohasekai.sfa"; + + private final CountDownLatch latch; + + public static SingBoxLauncher getInstance() { + if (instance == null) { + synchronized (SingBoxLauncher.class) { + if (instance == null) { + instance = new SingBoxLauncher(); + } + } + } + return instance; + } + + private final HandlerThread handlerThread; + + private SingBoxLauncher() { + latch = new CountDownLatch(1); + handlerThread = new HandlerThread("SingBoxThread"); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + } + + public void shutdown() { + handlerThread.quitSafely(); + } + + private void checkVPNApp(int count) { + if (count > 3) { + Log.e("checkVPNApp", "Invalid count parameter: " + count); + return; + } + + while (count <= 3) { + try { + if (!isProcessRunning(PKG)) { + startApp(PKG); + } else { + break; + } + // 避免 Thread.sleep,改用异步任务定时调度 + TimeUnit.SECONDS.sleep(2); + count++; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // 恢复中断状态 + Log.e("checkVPNApp", "Thread interrupted: " + e.getMessage()); + break; + } + } + } + + // 工具方法 + private boolean isProcessRunning(String pkg) { + String command = "ps -A | grep " + sanitizePackageName(pkg); + String result = ShellUtils.execRootCmdAndGetResult(command); + return result != null && !result.isEmpty(); + } + + private void startApp(String pkg) { + ShellUtils.execRootCmd("am start -n " + sanitizePackageName(pkg) + "/.ui.MainActivity"); + } + + private String sanitizePackageName(String pkg) { + if (pkg.matches("^[a-zA-Z0-9._-]+$")) { + return pkg; + } else { + throw new IllegalArgumentException("Unsafe package name: " + pkg); + } + } + + public boolean start(Context context,String originIp) { + checkVPNApp(0); + + Future future = executorService.submit(() -> { + + String content = "{\n" + + " \"dns\": {\n" + + " \"independent_cache\": true,\n" + + " \"servers\": [\n" + + " {\n" + + " \"tag\": \"cloudflare\",\n" + + " \"address\": \"tls://1.1.1.1\"\n" + + " },\n" + + " {\n" + + " \"tag\": \"local\",\n" + + " \"address\": \"tls://1.1.1.1\",\n" + + " \"detour\": \"direct\"\n" + + " },\n" + + " {\n" + + " \"tag\": \"remote\",\n" + + " \"address\": \"fakeip\"\n" + + " }\n" + + " ],\n" + + " \"rules\": [\n" + + " {\n" + + " \"server\": \"local\",\n" + + " \"outbound\": \"any\"\n" + + " },\n" + + " {\n" + + " \"server\": \"remote\",\n" + + " \"query_type\": [\n" + + " \"A\",\n" + + " \"AAAA\"\n" + + " ]\n" + + " }\n" + + " ],\n" + + " \"fakeip\": {\n" + + " \"enabled\": true,\n" + + " \"inet4_range\": \"198.18.0.0/15\",\n" + + " \"inet6_range\": \"fc00::/18\"\n" + + " },\n" + + " \"final\": \"cloudflare\"\n" + + " },\n" + + " \"inbounds\": [\n" + + " {\n" + + " \"type\": \"tun\",\n" + + " \"tag\": \"tun-in\",\n" + + " \"address\": [\n" + + " \"172.19.0.1/28\"\n" + + " ],\n" + + " \"auto_route\": true,\n" + + " \"sniff\": true,\n" + + " \"strict_route\": false,\n" + + " \"domain_strategy\": \"ipv4_only\"\n" + + " }\n" + + " ],\n" + + " \"log\": {\n" + + " \"level\": \"trace\"\n" + + " },\n" + + " \"outbounds\": [\n" + + " {\n" + + " \"type\": \"socks\",\n" + + " \"tag\": \"proxy\",\n" + + " \"version\": \"5\",\n" + + " \"network\": \"tcp\",\n" + + " \"udp_over_tcp\": {\n" + + " \"enabled\": true\n" + + " },\n" + + " \"username\": \"cut_team_protoc_vast-zone-custom-region-us\",\n" + + " \"password\": \"Leoliu811001\",\n" + + " \"server\": \"105bd58a50330382.na.ipidea.online\",\n" + + " \"server_port\": 2333\n" + + " },\n" + + " {\n" + + " \"type\": \"dns\",\n" + + " \"tag\": \"dns-out\"\n" + + " },\n" + + " {\n" + + " \"type\": \"direct\",\n" + + " \"tag\": \"direct\"\n" + + " },\n" + + " {\n" + + " \"type\": \"block\",\n" + + " \"tag\": \"block\"\n" + + " }\n" + + " ],\n" + + " \"route\": {\n" + + " \"final\": \"proxy\",\n" + + " \"auto_detect_interface\": true,\n" + + " \"rules\": [\n" + + " {\n" + + " \"protocol\": \"dns\",\n" + + " \"outbound\": \"dns-out\"\n" + + " },\n" + + " {\n" + + " \"protocol\": [\n" + + " \"stun\",\n" + + " \"quic\"\n" + + " ],\n" + + " \"outbound\": \"block\"\n" + + " },\n" + + " {\n" + + " \"ip_is_private\": true,\n" + + " \"outbound\": \"direct\"\n" + + " },\n" + + " {\n" + + " \"ip_cidr\": \"\",\n" + + " \"outbound\": \"direct\"\n" + + " },\n" + + " {\n" + + " \"domain\": \"cpm-api.resi-prod.resi-oversea.com\",\n" + + " \"domain_suffix\": \"resi-oversea.com\",\n" + + " \"outbound\": \"direct\"\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"; + + Intent intent = new Intent(); + intent.setAction(PKG + ".action.START_VPN"); + intent.putExtra("content", content); + intent.putExtra("originIp", originIp); + context.sendBroadcast(intent); + + handler.postDelayed(() -> checkVPN(context, 0), 1000); + }); + + try { + future.get(); // 等待任务完成,捕获异常 + } catch (Exception e) { + Log.e("TaskError", "Task failed to execute properly", e); + } + + try { + boolean result = latch.await(5, TimeUnit.SECONDS); // 设置超时时间 + if (!result) { + isVpnRunning = false; // 超时回退逻辑 + } + } catch (InterruptedException ignored) { + isVpnRunning = false; + } + return isVpnRunning; + } + + public void stop(Context context) { + if (context == null || PKG == null || PKG.trim().isEmpty()) { + Log.e("SingBoxLauncher", "Invalid context or package name."); + return; + } + + try { + // 构建广播 + Intent intent = new Intent(); + intent.setAction(PKG + ".action.STOP_VPN"); + intent.setPackage(PKG); // 安全性增强,限制接收者 + context.sendBroadcast(intent); + + // 检查进程是否仍在运行 + if (isProcessRunning(PKG)) { + // 添加延迟等待,确保广播完成 + Thread.sleep(1000); + + // 执行强制停止命令 + ShellUtils.execRootCmd("am force-stop " + sanitizePackageName(PKG)); + } + } catch (Exception e) { + Log.e("SingBoxLauncher", "Error while stopping VPN app: " + e.getMessage()); + } + } + + private void checkVPN(Context context, int count) { + try { + if (checkVPN(context)) { + isVpnRunning = true; + Log.d("VPNCheck", "VPN is running."); + latch.countDown(); + } else { + Log.d("VPNCheck", "VPN is not running. Retry count: " + count); + if (count > 3) { + isVpnRunning = false; + latch.countDown(); + } else { + final int countFinal = count + 1; + handler.removeCallbacksAndMessages(null); // 清理旧任务 + handler.postDelayed(() -> checkVPN(context, countFinal), 1000); + } + } + } catch (Exception e) { + Log.e("VPNCheck", "Error in checkVPN", e); + isVpnRunning = false; + latch.countDown(); + } + } + // 检查网络中的 VPN 连接 + private boolean checkVPN(Context context) { + ConnectivityManager connectivityManager = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (connectivityManager == null) return false; + + android.net.Network activeNetwork = connectivityManager.getActiveNetwork(); + if (activeNetwork == null) return false; + + android.net.NetworkCapabilities networkCapabilities = + connectivityManager.getNetworkCapabilities(activeNetwork); + if (networkCapabilities == null) return false; + + return networkCapabilities.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN); + } + + private boolean isVpnRunning; + private final ExecutorService executorService = Executors.newSingleThreadExecutor(); + private final Handler handler; +} diff --git a/app/src/main/java/com/example/studyapp/utils/HttpUtil.java b/app/src/main/java/com/example/studyapp/utils/HttpUtil.java new file mode 100644 index 0000000..cd1bf37 --- /dev/null +++ b/app/src/main/java/com/example/studyapp/utils/HttpUtil.java @@ -0,0 +1,59 @@ +package com.example.studyapp.utils; + +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; + +public class HttpUtil { + private static final String TAG = "HttpUtil"; + + public static String requestGet(String url) throws IOException { + Log.d(TAG, "[requestGet][url=" + url + "]"); + HttpURLConnection connection = (HttpURLConnection)new URL(url).openConnection(); + /*connection.setRequestProperty("Connection", "keep-alive"); + connection.setRequestProperty("Accept-Encoding", "gzip, deflate"); + connection.setRequestProperty("X-Requested-With", "com.android.chrome"); + connection.setRequestProperty("Sec-Fetch-Mode", "navigate"); + connection.setRequestProperty("Sec-Fetch-User", "?1"); + connection.setRequestProperty("Sec-Fetch-Dest", "document"); + connection.setRequestProperty("Sec-Fetch-Site", "none"); + connection.setRequestProperty("Sec-Ch-Ua-Mobile", "?1"); + connection.setRequestProperty("Sec-Ch-Ua-Platform", "Android"); + connection.setRequestProperty("Upgrade-Insecure-Requests", "1"); + connection.setInstanceFollowRedirects(true);*/ + connection.setDoInput(true); + connection.setConnectTimeout(15000); + connection.setReadTimeout(15000); + connection.setRequestMethod("GET"); + connection.setUseCaches(false); + connection.connect(); + + String responseBody = readResponseBody(connection); + Log.d(TAG, "[requestGet][response=" + responseBody + "]"); + connection.disconnect(); + + return responseBody; + } + + + + private static String readResponseBody(HttpURLConnection connection) throws IOException { + InputStream inputStream = connection.getInputStream(); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[0x400]; + while(true) { + int l = inputStream.read(buffer); + if(l == -1) { + break; + } + + byteArrayOutputStream.write(buffer, 0, l); + } + + return byteArrayOutputStream.toString(); + } +} diff --git a/app/src/main/java/com/example/studyapp/utils/IpUtil.java b/app/src/main/java/com/example/studyapp/utils/IpUtil.java new file mode 100644 index 0000000..aac7a0b --- /dev/null +++ b/app/src/main/java/com/example/studyapp/utils/IpUtil.java @@ -0,0 +1,67 @@ +package com.example.studyapp.utils; + +import android.text.TextUtils; + +import org.json.JSONObject; + +import java.util.concurrent.CompletableFuture; +import java.util.regex.Pattern; + +public class IpUtil { + public static boolean isValidIPAddress(String ipAddress) { + if ((ipAddress != null) && (!ipAddress.isEmpty())) + { + return Pattern.matches("^([1-9]|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])(\\.(\\d|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])){3}$", ipAddress); + } + return false; + } + public static String getClientIp(String url) { + try { + String s = HttpUtil.requestGet(url); + if (!TextUtils.isEmpty(s) && isValidIPAddress(s)) + { + return s; + } + } catch (Exception e) { + e.printStackTrace(); + } + return ""; + } + public static String getClientIp() { + return getClientIp("http://47.236.153.142/"); + } + + public static String safeClientIp() { + String result = getClientIp("http://47.236.153.142/");//SG + if (TextUtils.isEmpty(result)) + { + result = getClientIp("http://8.211.204.20/");//UK + } + return result; + } + + public static String checkClientIp(String excludeCountry) + { + try { + String s = HttpUtil.requestGet("https://get.geojs.io/v1/ip/country.json"); + if (!TextUtils.isEmpty(s)) + { + JSONObject json = new JSONObject(s); + if (excludeCountry.equalsIgnoreCase(json.getString("country"))) + { + return ""; + } + return json.getString("ip"); + } + } catch (Exception e) { + e.printStackTrace(); + } + return ""; + + } + + public static CompletableFuture getClientIpFuture() + { + return CompletableFuture.supplyAsync(IpUtil::safeClientIp); + } +} diff --git a/app/src/main/java/com/example/studyapp/utils/ShellUtils.java b/app/src/main/java/com/example/studyapp/utils/ShellUtils.java index 23c6387..a7fd258 100644 --- a/app/src/main/java/com/example/studyapp/utils/ShellUtils.java +++ b/app/src/main/java/com/example/studyapp/utils/ShellUtils.java @@ -54,10 +54,10 @@ public class ShellUtils { return false; } - public static String execRootCmdAndGetResult(String cmd) { + public static String execRootCmdAndGetResult(String cmd){ if (cmd == null || cmd.trim().isEmpty() || !isCommandSafe(cmd)) { Log.e("ShellUtils", "Unsafe or empty command. Aborting execution."); - return null; + throw new IllegalArgumentException("Unsafe or empty command."); } Process process = null; diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index cc4e4cc..23d75da 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -16,5 +16,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="点击开始运行" + android:contentDescription="运行脚本按钮" android:layout_centerInParent="true"/> \ No newline at end of file