diff --git a/.gitignore b/.gitignore index 8ef6b82..fe36a72 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ captures/ # External native build folder generated in Android Studio 2.2 and later .externalNativeBuild +.cxx/ # Google Services (e.g. APIs or Firebase) # google-services.json @@ -92,4 +93,4 @@ keystore.properties baselineProfiles/ TASKER_PLUGIN_DEVELOPMENT_GUIDE.md app/src/main/jniLibs/ -.devcontainer/devcontainer.json \ No newline at end of file +.devcontainer/devcontainer.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b6f83a7..af08660 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -135,6 +135,12 @@ android { buildConfigField("String", "FrpsFileName", "\"libfrps.so\"") buildConfigField("String", "FrpcConfigFileName", "\"frpc.toml\"") buildConfigField("String", "FrpsConfigFileName", "\"frps.toml\"") + + externalNativeBuild { + cmake { + cppFlags += listOf("-std=c++20", "-Wall", "-Wextra") + } + } } buildTypes { @@ -174,6 +180,12 @@ android { } namespace = "io.github.acedroidx.frp" + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + } + } + } androidComponents { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 81936d8..33f73f4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -69,6 +69,17 @@ android:value="explanation_for_special_use" /> + + + + + @@ -128,4 +140,4 @@ android:grantUriPermissions="true" /> - \ No newline at end of file + diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..3568042 --- /dev/null +++ b/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.22.1) + +project(http_proxy LANGUAGES CXX) + +add_library(http_proxy SHARED http_proxy.cpp) + +target_compile_features(http_proxy PRIVATE cxx_std_20) +target_link_libraries(http_proxy PRIVATE log) diff --git a/app/src/main/cpp/http_proxy.cpp b/app/src/main/cpp/http_proxy.cpp new file mode 100644 index 0000000..a36899b --- /dev/null +++ b/app/src/main/cpp/http_proxy.cpp @@ -0,0 +1,397 @@ +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr char kLogTag[] = "NativeHttpProxy"; +constexpr size_t kMaxHeaderBytes = 64 * 1024; +constexpr int kIoTimeoutSeconds = 30; + +std::atomic g_running{false}; +std::atomic g_listen_fd{-1}; +std::thread g_accept_thread; +std::mutex g_state_mutex; +std::mutex g_clients_mutex; +std::unordered_set g_client_fds; + +void log_error(const std::string &message) { + __android_log_print(ANDROID_LOG_ERROR, kLogTag, "%s", message.c_str()); +} + +void close_socket(int fd) { + if (fd >= 0) { + shutdown(fd, SHUT_RDWR); + close(fd); + } +} + +class TrackedSocket { +public: + explicit TrackedSocket(int fd = -1) : fd_(fd) { + if (fd_ >= 0) { + std::lock_guard lock(g_clients_mutex); + g_client_fds.insert(fd_); + } + } + + TrackedSocket(const TrackedSocket &) = delete; + TrackedSocket &operator=(const TrackedSocket &) = delete; + + TrackedSocket(TrackedSocket &&other) noexcept : fd_(std::exchange(other.fd_, -1)) {} + + ~TrackedSocket() { reset(); } + + int get() const { return fd_; } + + void reset() { + if (fd_ < 0) return; + { + std::lock_guard lock(g_clients_mutex); + g_client_fds.erase(fd_); + } + close_socket(fd_); + fd_ = -1; + } + +private: + int fd_; +}; + +bool send_all(int fd, const char *data, size_t length) { + while (length > 0) { + const ssize_t written = send(fd, data, length, MSG_NOSIGNAL); + if (written < 0) { + if (errno == EINTR) continue; + return false; + } + if (written == 0) return false; + data += written; + length -= static_cast(written); + } + return true; +} + +bool send_all(int fd, const std::string &data) { + return send_all(fd, data.data(), data.size()); +} + +void send_http_error(int fd, int status, const char *reason) { + const std::string body = std::to_string(status) + " " + reason + "\n"; + const std::string response = + "HTTP/1.1 " + std::to_string(status) + " " + reason + "\r\n" + "Connection: close\r\nContent-Type: text/plain\r\nContent-Length: " + + std::to_string(body.size()) + "\r\n\r\n" + body; + send_all(fd, response); +} + +std::string lower_ascii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; +} + +bool split_authority(const std::string &authority, std::string &host, std::string &port, + const char *default_port) { + if (authority.empty()) return false; + + if (authority.front() == '[') { + const size_t bracket = authority.find(']'); + if (bracket == std::string::npos) return false; + host = authority.substr(1, bracket - 1); + if (bracket + 1 < authority.size()) { + if (authority[bracket + 1] != ':') return false; + port = authority.substr(bracket + 2); + } else { + port = default_port; + } + } else { + const size_t colon = authority.rfind(':'); + if (colon != std::string::npos && authority.find(':') == colon) { + host = authority.substr(0, colon); + port = authority.substr(colon + 1); + } else { + host = authority; + port = default_port; + } + } + return !host.empty() && !port.empty(); +} + +int connect_remote(const std::string &host, const std::string &port) { + addrinfo hints{}; + hints.ai_socktype = SOCK_STREAM; + hints.ai_family = AF_UNSPEC; + + addrinfo *addresses = nullptr; + const int resolve_result = getaddrinfo(host.c_str(), port.c_str(), &hints, &addresses); + if (resolve_result != 0) { + log_error("DNS resolution failed for " + host + ": " + gai_strerror(resolve_result)); + return -1; + } + + int remote_fd = -1; + for (addrinfo *address = addresses; address != nullptr; address = address->ai_next) { + remote_fd = socket(address->ai_family, address->ai_socktype, address->ai_protocol); + if (remote_fd < 0) continue; + + timeval timeout{kIoTimeoutSeconds, 0}; + setsockopt(remote_fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + setsockopt(remote_fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + if (connect(remote_fd, address->ai_addr, address->ai_addrlen) == 0) break; + close(remote_fd); + remote_fd = -1; + } + freeaddrinfo(addresses); + return remote_fd; +} + +void relay_bidirectional(int first, int second) { + std::vector buffer(32 * 1024); + pollfd descriptors[2] = { + {first, POLLIN, 0}, + {second, POLLIN, 0}, + }; + + while (g_running.load(std::memory_order_relaxed)) { + const int result = poll(descriptors, 2, 1000); + if (result < 0) { + if (errno == EINTR) continue; + break; + } + if (result == 0) continue; + + for (int index = 0; index < 2; ++index) { + if ((descriptors[index].revents & (POLLIN | POLLHUP)) == 0) continue; + const int source = descriptors[index].fd; + const int destination = descriptors[1 - index].fd; + const ssize_t received = recv(source, buffer.data(), buffer.size(), 0); + if (received <= 0 || + !send_all(destination, buffer.data(), static_cast(received))) { + return; + } + } + } +} + +std::string find_header(const std::string &headers, const std::string &wanted_name) { + size_t line_start = headers.find("\r\n") + 2; + while (line_start != std::string::npos && line_start < headers.size()) { + const size_t line_end = headers.find("\r\n", line_start); + if (line_end == std::string::npos || line_end == line_start) break; + const size_t colon = headers.find(':', line_start); + if (colon != std::string::npos && colon < line_end) { + const std::string name = lower_ascii(headers.substr(line_start, colon - line_start)); + if (name == wanted_name) { + size_t value_start = colon + 1; + while (value_start < line_end && + (headers[value_start] == ' ' || headers[value_start] == '\t')) { + ++value_start; + } + return headers.substr(value_start, line_end - value_start); + } + } + line_start = line_end + 2; + } + return {}; +} + +void handle_client(int accepted_fd) { + TrackedSocket client(accepted_fd); + timeval timeout{kIoTimeoutSeconds, 0}; + setsockopt(client.get(), SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + setsockopt(client.get(), SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + + std::string request; + request.reserve(4096); + char chunk[8192]; + size_t header_end = std::string::npos; + while (request.size() < kMaxHeaderBytes) { + const ssize_t received = recv(client.get(), chunk, sizeof(chunk), 0); + if (received <= 0) return; + request.append(chunk, static_cast(received)); + header_end = request.find("\r\n\r\n"); + if (header_end != std::string::npos) break; + } + if (header_end == std::string::npos) { + send_http_error(client.get(), 431, "Request Header Fields Too Large"); + return; + } + + const size_t first_line_end = request.find("\r\n"); + const size_t first_space = request.find(' '); + const size_t second_space = request.find(' ', first_space + 1); + if (first_line_end == std::string::npos || first_space == std::string::npos || + second_space == std::string::npos || second_space > first_line_end) { + send_http_error(client.get(), 400, "Bad Request"); + return; + } + + const std::string method = request.substr(0, first_space); + const std::string target = request.substr(first_space + 1, second_space - first_space - 1); + std::string host; + std::string port; + + if (lower_ascii(method) == "connect") { + if (!split_authority(target, host, port, "443")) { + send_http_error(client.get(), 400, "Bad CONNECT Target"); + return; + } + TrackedSocket remote(connect_remote(host, port)); + if (remote.get() < 0) { + send_http_error(client.get(), 502, "Bad Gateway"); + return; + } + if (!send_all(client.get(), "HTTP/1.1 200 Connection Established\r\n\r\n")) return; + const size_t tunnel_data_start = header_end + 4; + if (tunnel_data_start < request.size() && + !send_all(remote.get(), request.data() + tunnel_data_start, + request.size() - tunnel_data_start)) { + return; + } + relay_bidirectional(client.get(), remote.get()); + return; + } + + std::string origin_target = target; + const std::string lower_target = lower_ascii(target); + if (lower_target.rfind("http://", 0) == 0) { + const size_t authority_start = 7; + const size_t path_start = target.find_first_of("/?#", authority_start); + const std::string authority = target.substr( + authority_start, + path_start == std::string::npos ? std::string::npos : path_start - authority_start + ); + if (!split_authority(authority, host, port, "80")) { + send_http_error(client.get(), 400, "Bad Proxy Target"); + return; + } + origin_target = path_start == std::string::npos ? "/" : target.substr(path_start); + if (!origin_target.empty() && origin_target.front() == '?') origin_target.insert(0, "/"); + const size_t fragment = origin_target.find('#'); + if (fragment != std::string::npos) origin_target.erase(fragment); + } else if (lower_target.rfind("https://", 0) == 0) { + send_http_error(client.get(), 400, "Use CONNECT For HTTPS"); + return; + } else { + if (!split_authority(find_header(request, "host"), host, port, "80")) { + send_http_error(client.get(), 400, "Host Header Required"); + return; + } + } + + TrackedSocket remote(connect_remote(host, port)); + if (remote.get() < 0) { + send_http_error(client.get(), 502, "Bad Gateway"); + return; + } + + // 上游 HTTP 服务器需要 origin-form 请求行;请求体及其他头保持字节级原样转发。 + std::string forwarded = method + " " + origin_target + request.substr(second_space); + if (!send_all(remote.get(), forwarded)) return; + relay_bidirectional(client.get(), remote.get()); +} + +void accept_connections(int listen_fd) { + while (g_running.load(std::memory_order_relaxed)) { + sockaddr_storage address{}; + socklen_t address_length = sizeof(address); + const int client_fd = accept(listen_fd, reinterpret_cast(&address), + &address_length); + if (client_fd < 0) { + if (errno == EINTR) continue; + if (g_running.load(std::memory_order_relaxed)) { + log_error("accept failed: " + std::string(strerror(errno))); + } + break; + } + std::thread(handle_client, client_fd).detach(); + } +} + +std::string start_proxy(int port) { + std::lock_guard state_lock(g_state_mutex); + if (g_running.load()) return {}; + + const int listen_fd = socket(AF_INET6, SOCK_STREAM, 0); + if (listen_fd < 0) return "socket: " + std::string(strerror(errno)); + + int enabled = 1; + int ipv6_only = 0; + setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &enabled, sizeof(enabled)); + setsockopt(listen_fd, IPPROTO_IPV6, IPV6_V6ONLY, &ipv6_only, sizeof(ipv6_only)); + + sockaddr_in6 address{}; + address.sin6_family = AF_INET6; + address.sin6_addr = in6addr_any; + address.sin6_port = htons(static_cast(port)); + if (bind(listen_fd, reinterpret_cast(&address), sizeof(address)) != 0) { + const std::string error = "bind: " + std::string(strerror(errno)); + close(listen_fd); + return error; + } + if (listen(listen_fd, 128) != 0) { + const std::string error = "listen: " + std::string(strerror(errno)); + close(listen_fd); + return error; + } + + g_listen_fd.store(listen_fd); + g_running.store(true); + g_accept_thread = std::thread(accept_connections, listen_fd); + __android_log_print(ANDROID_LOG_INFO, kLogTag, "Listening on [::]:%d", port); + return {}; +} + +void stop_proxy() { + std::lock_guard state_lock(g_state_mutex); + if (!g_running.exchange(false)) return; + + close_socket(g_listen_fd.exchange(-1)); + { + std::lock_guard clients_lock(g_clients_mutex); + for (const int fd : g_client_fds) shutdown(fd, SHUT_RDWR); + } + if (g_accept_thread.joinable()) g_accept_thread.join(); +} + +jstring make_jstring(JNIEnv *env, const std::string &value) { + return value.empty() ? nullptr : env->NewStringUTF(value.c_str()); +} + +} // namespace + +extern "C" JNIEXPORT jstring JNICALL +Java_io_github_acedroidx_frp_NativeHttpProxy_start(JNIEnv *env, jobject, jint port) { + if (port <= 0 || port > 65535) return make_jstring(env, "invalid port"); + return make_jstring(env, start_proxy(port)); +} + +extern "C" JNIEXPORT void JNICALL +Java_io_github_acedroidx_frp_NativeHttpProxy_stop(JNIEnv *, jobject) { + stop_proxy(); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_io_github_acedroidx_frp_NativeHttpProxy_isRunning(JNIEnv *, jobject) { + return g_running.load() ? JNI_TRUE : JNI_FALSE; +} diff --git a/app/src/main/java/io/github/acedroidx/frp/AutoStartBroadReceiver.kt b/app/src/main/java/io/github/acedroidx/frp/AutoStartBroadReceiver.kt index 72cd911..e384fb2 100644 --- a/app/src/main/java/io/github/acedroidx/frp/AutoStartBroadReceiver.kt +++ b/app/src/main/java/io/github/acedroidx/frp/AutoStartBroadReceiver.kt @@ -9,7 +9,12 @@ class AutoStartBroadReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val preferences = context.getSharedPreferences("data", Context.MODE_PRIVATE) when (intent.action) { - Intent.ACTION_BOOT_COMPLETED -> { + Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> { + // 代理开关独立于 frp 配置;升级或重启后恢复用户明确开启的代理服务。 + if (preferences.getBoolean(PreferencesKey.HTTP_PROXY_ENABLED, false)) { + HttpProxyService.start(context) + } + val autoStartOnBoot = preferences.getBoolean(PreferencesKey.AUTO_START, false) if (!autoStartOnBoot) return val configList = AutoStartHelper.loadAutoStartConfigs(context) @@ -74,4 +79,4 @@ class AutoStartBroadReceiver : BroadcastReceiver() { context.startService(mainIntent) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/github/acedroidx/frp/HttpProxyService.kt b/app/src/main/java/io/github/acedroidx/frp/HttpProxyService.kt new file mode 100644 index 0000000..be365c5 --- /dev/null +++ b/app/src/main/java/io/github/acedroidx/frp/HttpProxyService.kt @@ -0,0 +1,140 @@ +package io.github.acedroidx.frp + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.core.content.edit + +class HttpProxyService : Service() { + companion object { + const val PORT = 8080 + private const val TAG = "HttpProxyService" + private const val CHANNEL_ID = "http_proxy_bg" + private const val NOTIFICATION_ID = 2 + private const val ACTION_START = "io.github.acedroidx.frp.proxy.START" + private const val ACTION_STOP = "io.github.acedroidx.frp.proxy.STOP" + + fun start(context: Context) { + val intent = Intent(context, HttpProxyService::class.java).setAction(ACTION_START) + ContextCompat.startForegroundService(context, intent) + } + + fun stop(context: Context) { + // 先保存关闭状态,避免服务进程在停止途中被系统回收后又被粘性重启。 + context.getSharedPreferences("data", MODE_PRIVATE).edit { + putBoolean(PreferencesKey.HTTP_PROXY_ENABLED, false) + } + context.startService( + Intent(context, HttpProxyService::class.java).setAction(ACTION_STOP) + ) + } + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val preferences = getSharedPreferences("data", MODE_PRIVATE) + + if (intent?.action == ACTION_STOP) { + // 通知栏的停止按钮会直接发送 ACTION_STOP,也必须同步持久化开关状态。 + preferences.edit { putBoolean(PreferencesKey.HTTP_PROXY_ENABLED, false) } + NativeHttpProxy.stop() + stopForegroundCompat() + stopSelf() + return START_NOT_STICKY + } + + // startForegroundService 后必须立即进入前台;随后再启动 native 监听器。 + startForeground(NOTIFICATION_ID, createNotification()) + val shouldRun = intent?.action == ACTION_START || + preferences.getBoolean(PreferencesKey.HTTP_PROXY_ENABLED, false) + if (!shouldRun) { + stopForegroundCompat() + stopSelf() + return START_NOT_STICKY + } + + val error = NativeHttpProxy.start(PORT) + if (error != null) { + Log.e(TAG, "Unable to start native proxy: $error") + preferences.edit { putBoolean(PreferencesKey.HTTP_PROXY_ENABLED, false) } + val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + manager.notify(NOTIFICATION_ID, createNotification(error)) + stopSelf() + return START_NOT_STICKY + } + + preferences.edit { putBoolean(PreferencesKey.HTTP_PROXY_ENABLED, true) } + return START_STICKY + } + + override fun onDestroy() { + NativeHttpProxy.stop() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun stopForegroundCompat() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + } + + private fun createNotification(error: String? = null): Notification { + val openIntent = Intent(this, SettingsActivity::class.java) + val openPendingIntent = PendingIntent.getActivity( + this, 0, openIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + val stopPendingIntent = PendingIntent.getService( + this, + 1, + Intent(this, HttpProxyService::class.java).setAction(ACTION_STOP), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(getString(R.string.http_proxy_notification_title)) + .setContentText( + error ?: getString(R.string.http_proxy_notification_content, PORT) + ) + .setContentIntent(openPendingIntent) + .setOngoing(error == null) + .setOnlyAlertOnce(true) + .addAction( + R.drawable.ic_baseline_delete_24, + getString(R.string.stop), + stopPendingIntent + ) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.http_proxy_notification_channel), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.http_proxy_notification_channel_desc) + } + (getSystemService(NOTIFICATION_SERVICE) as NotificationManager) + .createNotificationChannel(channel) + } +} diff --git a/app/src/main/java/io/github/acedroidx/frp/NativeHttpProxy.kt b/app/src/main/java/io/github/acedroidx/frp/NativeHttpProxy.kt new file mode 100644 index 0000000..efbfe0b --- /dev/null +++ b/app/src/main/java/io/github/acedroidx/frp/NativeHttpProxy.kt @@ -0,0 +1,14 @@ +package io.github.acedroidx.frp + +object NativeHttpProxy { + init { + System.loadLibrary("http_proxy") + } + + /** 成功时返回 null,失败时返回可供日志和通知展示的错误信息。 */ + external fun start(port: Int): String? + + external fun stop() + + external fun isRunning(): Boolean +} diff --git a/app/src/main/java/io/github/acedroidx/frp/PreferencesKey.kt b/app/src/main/java/io/github/acedroidx/frp/PreferencesKey.kt index 3c3856f..ebe7199 100644 --- a/app/src/main/java/io/github/acedroidx/frp/PreferencesKey.kt +++ b/app/src/main/java/io/github/acedroidx/frp/PreferencesKey.kt @@ -18,4 +18,5 @@ object PreferencesKey { const val QUICK_TILE_CONFIG_TYPE = "quick_tile_config_type" const val QUICK_TILE_CONFIG_NAME = "quick_tile_config_name" const val FIRST_LAUNCH_DONE = "first_launch_done" -} \ No newline at end of file + const val HTTP_PROXY_ENABLED = "http_proxy_enabled" +} diff --git a/app/src/main/java/io/github/acedroidx/frp/SettingsActivity.kt b/app/src/main/java/io/github/acedroidx/frp/SettingsActivity.kt index fcc2074..fdc769c 100644 --- a/app/src/main/java/io/github/acedroidx/frp/SettingsActivity.kt +++ b/app/src/main/java/io/github/acedroidx/frp/SettingsActivity.kt @@ -81,6 +81,7 @@ class SettingsActivity : ComponentActivity() { private val hideServiceToast = MutableStateFlow(false) private val allowConfigRead = MutableStateFlow(false) private val allowConfigWrite = MutableStateFlow(false) + private val httpProxyEnabled = MutableStateFlow(false) private val quickTileConfig = MutableStateFlow(null) private lateinit var preferences: SharedPreferences @@ -187,11 +188,13 @@ class SettingsActivity : ComponentActivity() { val isHideServiceToast by hideServiceToast.collectAsStateWithLifecycle(false) val isConfigReadAllowed by allowConfigRead.collectAsStateWithLifecycle(false) val isConfigWriteAllowed by allowConfigWrite.collectAsStateWithLifecycle(false) + val isHttpProxyEnabled by httpProxyEnabled.collectAsStateWithLifecycle(false) val currentQuickTileConfig by quickTileConfig.collectAsStateWithLifecycle(null) val configs by allConfigs.collectAsStateWithLifecycle(emptyList()) var showAutoStartHelp by remember { mutableStateOf(false) } var showConfigIoHelp by remember { mutableStateOf(false) } + var showHttpProxyHelp by remember { mutableStateOf(false) } val themeOptions = listOf( ThemeModeKeys.DARK to stringResource(R.string.theme_mode_dark), @@ -213,6 +216,13 @@ class SettingsActivity : ComponentActivity() { onDismiss = { showConfigIoHelp = false }) } + if (showHttpProxyHelp) { + HelpDialog( + title = stringResource(R.string.http_proxy_title), + message = stringResource(R.string.http_proxy_help, HttpProxyService.PORT), + onDismiss = { showHttpProxyHelp = false }) + } + Column( modifier = Modifier .fillMaxWidth() @@ -318,6 +328,55 @@ class SettingsActivity : ComponentActivity() { } } + // Native HTTP/HTTPS CONNECT 代理。默认监听所有接口,便于本机和局域网客户端使用。 + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(2.dp)) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.http_proxy_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource( + R.string.http_proxy_endpoint, + HttpProxyService.PORT + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = { showHttpProxyHelp = true }) { + Icon( + painter = painterResource(id = R.drawable.help_24px), + contentDescription = stringResource(R.string.content_desc_help) + ) + } + } + HorizontalDivider() + SettingItemWithSwitch( + title = stringResource(R.string.http_proxy_switch), + checked = isHttpProxyEnabled, + onCheckedChange = { checked -> + preferences.edit { + putBoolean(PreferencesKey.HTTP_PROXY_ENABLED, checked) + } + httpProxyEnabled.value = checked + if (checked) { + HttpProxyService.start(this@SettingsActivity) + } else { + HttpProxyService.stop(this@SettingsActivity) + } + } + ) + } + } + // frp 自启动设置分类(Card) Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(2.dp)) { @@ -731,6 +790,8 @@ class SettingsActivity : ComponentActivity() { hideServiceToast.value = preferences.getBoolean(PreferencesKey.HIDE_SERVICE_TOAST, false) allowConfigRead.value = preferences.getBoolean(PreferencesKey.ALLOW_CONFIG_READ, false) allowConfigWrite.value = preferences.getBoolean(PreferencesKey.ALLOW_CONFIG_WRITE, false) + httpProxyEnabled.value = + preferences.getBoolean(PreferencesKey.HTTP_PROXY_ENABLED, false) // 读取快捷开关配置 loadQuickTileConfig() @@ -770,7 +831,7 @@ class SettingsActivity : ComponentActivity() { zipOut ) - val prefsFile = File(dataDir, "shared_prefs/data.xml") + val prefsFile = File(applicationInfo.dataDir, "shared_prefs/data.xml") if (prefsFile.exists()) { appendFileToZip(prefsFile, "shared_prefs/data.xml", zipOut) } @@ -802,10 +863,13 @@ class SettingsActivity : ComponentActivity() { } rawName == "shared_prefs/data.xml" -> { - val prefsDir = File(dataDir, "shared_prefs") + val prefsDir = File(applicationInfo.dataDir, "shared_prefs") prefsDir.mkdirs() val target = File(prefsDir, "data.xml") - ensureInsideDir(target, prefsDir.parentFile ?: dataDir) + ensureInsideDir( + target, + prefsDir.parentFile ?: File(applicationInfo.dataDir) + ) FileOutputStream(target).use { output -> zipIn.copyTo(output) } diff --git a/app/src/main/java/io/github/acedroidx/frp/ShellThread.kt b/app/src/main/java/io/github/acedroidx/frp/ShellThread.kt index a9add3f..2df2d40 100644 --- a/app/src/main/java/io/github/acedroidx/frp/ShellThread.kt +++ b/app/src/main/java/io/github/acedroidx/frp/ShellThread.kt @@ -10,7 +10,8 @@ class ShellThread( val envp: Map = emptyMap(), val outputCallback: (text: String) -> Unit ) : Thread() { - private lateinit var process: Process + @Volatile + private var process: Process? = null override fun run() { try { @@ -21,10 +22,11 @@ class ShellThread( } processBuilder.redirectErrorStream(true) // 合并错误流 - process = processBuilder.start() + val runningProcess = processBuilder.start() + process = runningProcess // 处理输出流 - process.inputStream.bufferedReader().use { reader -> + runningProcess.inputStream.bufferedReader().use { reader -> try { var line: String? = null while (!isInterrupted && reader.readLine().also { line = it } != null) { @@ -37,7 +39,7 @@ class ShellThread( } // 等待进程结束并读取退出码 - val exitCode = process.waitFor() + val exitCode = runningProcess.waitFor() outputCallback("Process exited with code: $exitCode") } catch (e: Exception) { @@ -49,15 +51,17 @@ class ShellThread( } fun stopProcess() { + // 进程创建失败时没有可清理的对象,直接返回,避免掩盖真正的启动异常。 + val runningProcess = process ?: return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - process.destroyForcibly() + runningProcess.destroyForcibly() } else { - process.destroy() + runningProcess.destroy() } } catch (e: Exception) { e.printStackTrace() outputCallback("Error stopping process: ${e.message}") } } -} \ No newline at end of file +} diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 366fbde..0a8ed5a 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -123,4 +123,12 @@ 备份导出失败:%1$s 备份导入成功 备份导入失败:%1$s + HTTP / HTTPS 代理 + 启用代理服务器 + 监听所有网络接口,端口 %1$d + 原生代理在端口 %1$d 接收 HTTP 请求和 HTTPS CONNECT 隧道。请在客户端中将 Android 设备 IP 和此端口设置为 HTTP 代理。\n\n同一网络中的设备均可访问此代理,且代理没有身份验证,因此请仅在可信网络中开启。HTTPS 流量仅进行端到端隧道转发,不会被解密。\n\n普通进程被杀后,Android 可重启此前台服务;设备重启后也会恢复。Android 不允许任何应用在被明确“强行停止”后继续运行,重新打开应用后方可恢复。 + HTTP 代理服务 + 保持原生 HTTP 和 HTTPS 代理运行 + HTTP / HTTPS 代理正在运行 + 正在监听端口 %1$d diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 419f004..a90a033 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -124,4 +124,12 @@ Backup export failed: %1$s Backup imported Backup import failed: %1$s + HTTP / HTTPS Proxy + Enable proxy server + Listening on all interfaces, port %1$d + This native proxy accepts HTTP requests and HTTPS CONNECT tunnels on port %1$d. Set the Android device IP and this port as the HTTP proxy on a client.\n\nThe proxy is available to devices on the same network and has no authentication, so only enable it on trusted networks. HTTPS traffic is tunneled end-to-end and is not decrypted.\n\nAndroid can restart the foreground service after an ordinary process kill and after reboot. Android does not allow any app to run after an explicit Force stop until the app is opened again. + HTTP proxy service + Keeps the native HTTP and HTTPS proxy running + HTTP / HTTPS proxy is running + Listening on port %1$d diff --git a/scripts/update_frp_binaries.sh b/scripts/update_frp_binaries.sh index d29548e..4e32552 100755 --- a/scripts/update_frp_binaries.sh +++ b/scripts/update_frp_binaries.sh @@ -68,29 +68,29 @@ fi log "Fetching release info from GitHub: ${API_URL}" -AUTH_ARGS=() -if [[ -n "$GITHUB_TOKEN" ]]; then - AUTH_ARGS=( -H "Authorization: token ${GITHUB_TOKEN}" ) -fi - if [[ $DRY_RUN -eq 1 ]]; then log "DRY RUN: will not perform downloads or write files. Showing intended behavior..." fi # Get release JSON -# Fetch release JSON (fail hard if GitHub returns an error) -if ! release_json=$(curl -sSL --fail "${API_URL}" -H "Accept: application/vnd.github.v3+json" "${AUTH_ARGS[@]}" ); then +# Fetch release JSON (fail hard if GitHub returns an error). +# macOS 自带的 Bash 3.2 在 set -u 下展开空数组会报错,因此分别处理有无 token 的情况。 +if [[ -n "$GITHUB_TOKEN" ]]; then + release_json=$(curl -sSL --fail "${API_URL}" \ + -H "Accept: application/vnd.github.v3+json" \ + -H "Authorization: token ${GITHUB_TOKEN}") || { + err "Failed to fetch release info from GitHub (${API_URL})"; exit 3; + } +elif ! release_json=$(curl -sSL --fail "${API_URL}" -H "Accept: application/vnd.github.v3+json"); then err "Failed to fetch release info from GitHub (${API_URL})"; exit 3 fi if [[ -z "${release_json}" || "${release_json}" == "null" ]]; then err "Release info is empty or null from GitHub"; exit 3 fi -# Architecture mapping -declare -A ARCH_MAP -ARCH_MAP["arm64-v8a"]="android_arm64" -ARCH_MAP["x86_64"]="linux_amd64" -ARCH_MAP["armeabi-v7a"]="linux_arm" +# Architecture mapping。使用并行索引数组以兼容 macOS 自带的 Bash 3.2。 +ABI_DIRS=("arm64-v8a" "x86_64" "armeabi-v7a") +ARCH_MAPPINGS=("android_arm64" "linux_amd64" "linux_arm") # Make DEST_BASE if not exists if [[ $DRY_RUN -eq 0 && ! -d ${DEST_BASE} ]]; then @@ -238,8 +238,9 @@ process_asset() { } # Process each ARCH -for abi in "${!ARCH_MAP[@]}"; do - mapping=${ARCH_MAP[$abi]} +for ((arch_index = 0; arch_index < ${#ABI_DIRS[@]}; arch_index++)); do + abi=${ABI_DIRS[$arch_index]} + mapping=${ARCH_MAPPINGS[$arch_index]} # For linux arm, accept both linux_arm and linux_arm_hf (hf = hardware float) in matching if [[ "$mapping" == "linux_arm" ]]; then # 优先尝试 linux_arm_hf,其次退回 linux_arm