Record project continuation changes

This commit is contained in:
2026-05-12 21:02:29 +03:00
parent 3059d1d7a3
commit 8f69d53193
339 changed files with 101111 additions and 1769 deletions
@@ -0,0 +1,2 @@
#Fri May 01 13:10:35 MSK 2026
gradle.version=9.5.0
+41
View File
@@ -0,0 +1,41 @@
# RAP Android VPN
This is the Android client for the experimental RAP VPN service.
Implemented now:
- login through `/auth/login`;
- trusted-device reconnect through `/auth/refresh` without retyping the password
while the device session is valid;
- load organization-scoped VPN client profile from `/clusters/{clusterID}/vpn/client-profile`;
- request Android VPN permission and create a `VpnService` TUN interface;
- relay TUN packets through the Control Plane HTTP packet relay to the active
`home-1` gateway lease.
- user-facing HOME-first screen: connect/disconnect is primary, while backend,
cluster, organization, login, and password are kept in the settings dialog;
- saved connection settings in app preferences so repeat connects do not require
retyping the profile.
- encrypted refresh-token storage through Android Keystore. If the trusted
device session is revoked or expires, the app asks for the password once and
then rotates the device keys/profile again.
This is still a lab runtime, not a production WireGuard/IPsec implementation.
The active Linux gateway node must be able to create `/dev/net/tun`, run `ip`,
`sysctl`, and `iptables`, and enable NAT for `10.77.0.0/24`.
Build from this repository on Windows:
```powershell
$env:ANDROID_HOME="C:\Android\Sdk"
$env:ANDROID_SDK_ROOT="C:\Android\Sdk"
pwsh -ExecutionPolicy Bypass -File ..\..\scripts\android\build-android-apk.ps1
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
Or run directly from the project:
```powershell
$env:ANDROID_HOME="C:\Android\Sdk"
$env:ANDROID_SDK_ROOT="C:\Android\Sdk"
gradle assembleDebug
```
+49
View File
@@ -0,0 +1,49 @@
plugins {
id "com.android.application"
}
android {
namespace "su.cin.rapvpn"
compileSdk 35
signingConfigs {
release {
// Для тестовой среды используем debug-сертификат как fallback, чтобы APK всегда можно было установить.
// Когда будет отдельный keystore для prod/release — заменим на него в этом блоке.
initWith signingConfigs.debug
}
}
buildFeatures {
buildConfig true
}
def normalizeGradleString = { value ->
return (value == null ? "" : value.toString()).replace("\\", "\\\\").replace("\"", "\\\"")
}
def defaultBackendUrl = project.findProperty("RAP_ANDROID_DEFAULT_BACKEND_URL") ?: "http://vpn.cin.su:19191/api/v1"
def defaultClusterId = project.findProperty("RAP_ANDROID_DEFAULT_CLUSTER_ID") ?: "cfc0743d-d960-49fb-9de8-96e063d5e4aa"
def defaultOrganizationId = project.findProperty("RAP_ANDROID_DEFAULT_ORGANIZATION_ID") ?: "125ff8b2-5ac1-4406-9bbb-ebbe18f7c7ed"
defaultConfig {
applicationId "su.cin.rapvpn"
minSdk 26
targetSdk 35
versionCode 159
versionName "0.2.159"
buildConfigField "String", "DEFAULT_BACKEND_URL", "\"${normalizeGradleString(defaultBackendUrl)}\""
buildConfigField "String", "DEFAULT_CLUSTER_ID", "\"${normalizeGradleString(defaultClusterId)}\""
buildConfigField "String", "DEFAULT_ORGANIZATION_ID", "\"${normalizeGradleString(defaultOrganizationId)}\""
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}
dependencies {
implementation "com.squareup.okhttp3:okhttp:5.3.2"
}
@@ -0,0 +1,69 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application
android:allowBackup="false"
android:label="RAP VPN"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:name=".RdpActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="false"
android:screenOrientation="landscape" />
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".TestVpnActivity"
android:exported="true"
android:theme="@style/AppTheme" />
<activity
android:name=".TestTrafficActivity"
android:exported="true"
android:theme="@style/AppTheme" />
<service
android:name=".RapVpnService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="vpn" />
</service>
<service
android:name=".RapDiagnosticService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="vpn-diagnostics" />
</service>
<receiver
android:name=".RapAutostartReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,140 @@
package su.cin.rapvpn;
import android.util.Base64;
import org.json.JSONObject;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import okhttp3.Request;
final class FabricServiceChannel {
final boolean enabled;
final String channelId;
final String token;
final String pathTemplate;
final String webSocketPathTemplate;
final String authorityPayloadHeader;
final String authoritySignatureHeader;
final String serviceClass;
final String channelClass;
FabricServiceChannel() {
this(false, "", "", "", "", "", "", "", "");
}
private FabricServiceChannel(
boolean enabled,
String channelId,
String token,
String pathTemplate,
String webSocketPathTemplate,
String authorityPayloadHeader,
String authoritySignatureHeader,
String serviceClass,
String channelClass) {
this.enabled = enabled;
this.channelId = safe(channelId);
this.token = safe(token);
this.pathTemplate = safe(pathTemplate);
this.webSocketPathTemplate = safe(webSocketPathTemplate);
this.authorityPayloadHeader = safe(authorityPayloadHeader);
this.authoritySignatureHeader = safe(authoritySignatureHeader);
this.serviceClass = safe(serviceClass);
this.channelClass = safe(channelClass);
}
static FabricServiceChannel fromLease(JSONObject lease) {
if (lease == null) {
return new FabricServiceChannel();
}
JSONObject tokenObject = lease.optJSONObject("token");
JSONObject entryHttp = lease.optJSONObject("entry_http");
String channelId = lease.optString("channel_id", "");
String token = tokenObject == null ? "" : tokenObject.optString("token", "");
String pathTemplate = entryHttp == null ? "" : entryHttp.optString("path_template", "");
String wsTemplate = entryHttp == null ? "" : entryHttp.optString("websocket_path_template", "");
String serviceClass = lease.optString("service_class", "vpn_packets");
String channelClass = "vpn_packet";
JSONObject authoritySignature = lease.optJSONObject("authority_signature");
JSONObject authorityPayload = lease.optJSONObject("authority_payload");
String payloadHeader = authorityPayload == null ? "" : encodeHeader(authorityPayload.toString());
String signatureHeader = authoritySignature == null ? "" : encodeHeader(authoritySignature.toString());
boolean enabled = !channelId.isEmpty() && token.startsWith("rap_fsc_") && !pathTemplate.isEmpty();
return new FabricServiceChannel(enabled, channelId, token, pathTemplate, wsTemplate, payloadHeader, signatureHeader, serviceClass, channelClass);
}
String packetPath(String clusterId, String vpnConnectionId, boolean webSocket) {
return packetPathForBase("", clusterId, vpnConnectionId, webSocket);
}
String packetPathForBase(String baseUrl, String clusterId, String vpnConnectionId, boolean webSocket) {
String template = webSocket && !webSocketPathTemplate.isEmpty() ? webSocketPathTemplate : pathTemplate;
if (!enabled || template.isEmpty()) {
return "";
}
String path = template
.replace("{cluster_id}", safe(clusterId))
.replace("{clusterID}", safe(clusterId))
.replace("{channel_id}", channelId)
.replace("{channelID}", channelId)
.replace("{resource_id}", safe(vpnConnectionId))
.replace("{resourceID}", safe(vpnConnectionId))
.replace("{vpn_connection_id}", safe(vpnConnectionId))
.replace("{vpnConnectionID}", safe(vpnConnectionId));
path = path.startsWith("/") ? path : "/" + path;
String basePath = "";
try {
URI uri = URI.create(baseUrl == null ? "" : baseUrl);
basePath = uri.getRawPath() == null ? "" : trimRight(uri.getRawPath());
} catch (Exception ignored) {
}
if (basePath.endsWith("/api/v1") && path.startsWith("/api/v1/")) {
path = path.substring("/api/v1".length());
}
return path;
}
Request.Builder applyHeaders(Request.Builder builder) {
if (!enabled || builder == null) {
return builder;
}
builder.header("X-RAP-Service-Channel-Token", token);
builder.header("X-RAP-Fabric-Channel-ID", channelId);
if (!serviceClass.isEmpty()) {
builder.header("X-RAP-Service-Class", serviceClass);
}
if (!channelClass.isEmpty()) {
builder.header("X-RAP-Channel-Class", channelClass);
}
if (!authorityPayloadHeader.isEmpty()) {
builder.header("X-RAP-Service-Channel-Authority-Payload", authorityPayloadHeader);
}
if (!authoritySignatureHeader.isEmpty()) {
builder.header("X-RAP-Service-Channel-Authority-Signature", authoritySignatureHeader);
}
return builder;
}
private static String encodeHeader(String value) {
if (value == null || value.isEmpty()) {
return "";
}
return Base64.encodeToString(value.getBytes(StandardCharsets.UTF_8), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
}
private static String safe(String value) {
return value == null ? "" : value.trim();
}
private static String trimRight(String value) {
if (value == null) {
return "";
}
while (value.endsWith("/")) {
value = value.substring(0, value.length() - 1);
}
return value;
}
}
@@ -0,0 +1,946 @@
package su.cin.rapvpn;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.SharedPreferences;
import android.content.Intent;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.VpnService;
import android.os.Bundle;
import android.text.InputType;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.Locale;
public class MainActivity extends Activity {
private static final String APP_VERSION = BuildConfig.VERSION_NAME;
private static final String DEFAULT_BACKEND_URL = BuildConfig.DEFAULT_BACKEND_URL;
private static final String DEFAULT_CLUSTER_ID = BuildConfig.DEFAULT_CLUSTER_ID;
private static final String DEFAULT_ORGANIZATION_ID = BuildConfig.DEFAULT_ORGANIZATION_ID;
private static final String PREF_SELECTED_EXIT_NODE_ID = "selected_exit_node_id";
private static final int VPN_PREPARE_REQUEST = 42;
private static final String PREFS = "rap-vpn";
private static final String PREF_DEVICE_FINGERPRINT = "device_fingerprint";
private static final String PREF_REFRESH_TOKEN = "refresh_token";
private static final String PREF_REFRESH_EXPIRES_AT = "refresh_expires_at";
private static final String PREF_USER_ID = "user_id";
private static final String PREF_DEVICE_ID = "device_id";
private static final String PREF_PROFILE_JSON = "profile_json";
private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id";
static final String PREF_FORCE_FULL_TUNNEL = "force_full_tunnel";
private EditText backendUrl;
private EditText clusterId;
private EditText organizationId;
private EditText email;
private EditText password;
private TextView status;
private TextView profileSummary;
private TextView serverDirectory;
private TextView runtimeStatus;
private String profileJson = "";
private String vpnConnectionId = "";
private JSONArray lastResources = new JSONArray();
private RapApiClient.AuthContext authContext = null;
private SharedPreferences prefs;
private SharedPreferences runtimePrefs;
private SecureTokenStore secureTokens;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
runtimePrefs = getSharedPreferences("rap-vpn-runtime", MODE_PRIVATE);
secureTokens = new SecureTokenStore(this);
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setBackgroundColor(0xff101820);
int pad = dp(20);
root.setPadding(pad, pad, pad, pad);
backendUrl = field("Backend URL", preferredBackendUrl());
clusterId = field("Cluster ID", prefs.getString("cluster_id", DEFAULT_CLUSTER_ID));
organizationId = field("Organization ID", prefs.getString("organization_id", DEFAULT_ORGANIZATION_ID));
email = field("Email", prefs.getString("email", "m"));
password = field("Password", "");
password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
normalizeAndPersistDefaults();
if (!prefs.contains(PREF_FORCE_FULL_TUNNEL)) {
prefs.edit().putBoolean(PREF_FORCE_FULL_TUNNEL, true).apply();
}
profileJson = prefs.getString(PREF_PROFILE_JSON, "");
vpnConnectionId = prefs.getString(PREF_VPN_CONNECTION_ID, "");
restoreAuthContext();
TextView title = new TextView(this);
title.setText("RAP HOME VPN " + APP_VERSION);
title.setTextColor(0xffffffff);
title.setTextSize(26);
title.setPadding(0, 0, 0, dp(8));
profileSummary = new TextView(this);
profileSummary.setTextColor(0xffc8d6df);
profileSummary.setTextSize(14);
profileSummary.setText(summaryText());
serverDirectory = new TextView(this);
serverDirectory.setTextColor(0xffe8eef2);
serverDirectory.setTextSize(15);
serverDirectory.setPadding(0, dp(14), 0, dp(14));
serverDirectory.setText("");
status = new TextView(this);
status.setTextColor(0xffd8eadf);
status.setPadding(0, dp(14), 0, dp(14));
status.setText("Готово. Версия " + APP_VERSION + ".");
runtimeStatus = new TextView(this);
runtimeStatus.setTextColor(0xff9fb6c2);
runtimeStatus.setTextSize(13);
runtimeStatus.setPadding(0, 0, 0, dp(10));
runtimeStatus.setText(runtimeStatusText());
Button load = new Button(this);
load.setText("Войти / обновить профиль");
load.setOnClickListener(v -> loadProfile(false));
Button start = new Button(this);
start.setText("Включить HOME VPN");
start.setOnClickListener(v -> prepareVpn());
Button stop = new Button(this);
stop.setText("Отключить VPN");
stop.setOnClickListener(v -> {
Intent stopIntent = new Intent(this, RapVpnService.class);
stopIntent.setAction(RapVpnService.ACTION_STOP);
try {
startService(stopIntent);
} catch (Exception ignored) {
}
runtimePrefs.edit()
.putString("state", "stopped")
.putString("message", "stop requested from app")
.putLong("updated_at", System.currentTimeMillis())
.apply();
status.setText("Отключаю VPN...");
runtimeStatus.postDelayed(() -> {
runtimeStatus.setText(runtimeStatusText());
status.setText(isSystemVpnActive() ? "VPN еще активен в Android. Повторяю остановку..." : "VPN отключен.");
if (isSystemVpnActive()) {
try {
startService(stopIntent);
} catch (Exception ignored) {
}
runtimeStatus.postDelayed(() -> {
if (isSystemVpnActive()) {
runtimePrefs.edit()
.putString("state", "stopped")
.putString("message", "force stop app process after VPN stop request")
.putLong("updated_at", System.currentTimeMillis())
.apply();
android.os.Process.killProcess(android.os.Process.myPid());
}
}, 1800);
}
}, 1200);
});
Button settings = new Button(this);
settings.setText("Настройки");
settings.setOnClickListener(v -> showSettingsDialog());
Button servers = new Button(this);
servers.setText("Открыть удаленный сервер");
servers.setOnClickListener(v -> showServerPicker());
root.addView(title);
root.addView(profileSummary);
root.addView(load);
root.addView(servers);
root.addView(start);
root.addView(stop);
root.addView(settings);
root.addView(status);
root.addView(runtimeStatus);
setContentView(root);
scheduleRuntimeStatusRefresh();
if (authContext != null && !authContext.deviceId.isEmpty()) {
startDiagnosticChannel();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
}
private EditText field(String hint, String value) {
EditText input = new EditText(this);
input.setHint(hint);
input.setText(value);
input.setSingleLine(true);
return input;
}
private void loadProfile() {
loadProfile(false);
}
private void loadProfile(boolean startAfterLoad) {
status.setText("Загрузка...");
saveSettings();
new Thread(() -> {
try {
RapApiClient client = new RapApiClient(backendUrl.getText().toString(), this);
authContext = authenticate(client);
String activeOrganizationId = resolveOrganizationId(client, authContext.userId);
String requestedExitNodeId = selectedExitNodeId();
profileJson = client.vpnClientProfile(
clusterId.getText().toString(),
activeOrganizationId,
authContext.userId,
requestedExitNodeId
);
vpnConnectionId = firstConnectionId(profileJson);
saveProfileState();
JSONObject resourcePayload = client.resources(activeOrganizationId, authContext.userId);
lastResources = resourcePayload.optJSONArray("resources");
if (lastResources == null) {
lastResources = new JSONArray();
}
String resourcesText = resourcesText(resourcePayload);
runOnUiThread(() -> {
profileSummary.setText(summaryText());
serverDirectory.setText(resourcesText);
status.setText(startAfterLoad ? "Профиль обновлен. Запускаю VPN..." : "Профиль и ключи устройства обновлены.");
startDiagnosticChannel();
if (startAfterLoad) {
requestVpnPermission();
}
});
} catch (Exception ex) {
runOnUiThread(() -> {
String message = friendlyError(ex);
boolean canUseSavedProfile = startAfterLoad && !profileJson.isEmpty() && !vpnConnectionId.isEmpty();
if (canUseSavedProfile) {
status.setText("Профиль сейчас не обновился: " + message + ". Запускаю VPN с сохраненным рабочим профилем.");
startDiagnosticChannel();
requestVpnPermission();
return;
}
status.setText("Ошибка профиля: " + message);
if (message.contains("логин") || message.contains("пароль") || message.contains("Сессия устройства")) {
clearSavedAuth(false);
showSettingsDialog();
}
});
}
}).start();
}
private void prepareVpn() {
loadProfile(true);
status.setText("Обновляю сессию устройства и VPN-профиль...");
}
private void requestVpnPermission() {
if (profileJson.isEmpty()) {
status.setText("VPN-профиль не загружен.");
return;
}
Intent prepare = VpnService.prepare(this);
if (prepare != null) {
startActivityForResult(prepare, VPN_PREPARE_REQUEST);
return;
}
startVpn();
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == VPN_PREPARE_REQUEST && resultCode == RESULT_OK) {
startVpn();
}
}
private void startVpn() {
Intent intent = new Intent(this, RapVpnService.class);
intent.putExtra(RapVpnService.EXTRA_PROFILE_JSON, profileJson);
intent.putExtra(RapVpnService.EXTRA_BACKEND_URL, backendUrl.getText().toString());
intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, clusterId.getText().toString());
intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, vpnConnectionId);
startForegroundService(intent);
status.setText("VPN запускается. Версия " + APP_VERSION + ". Backend: " + backendUrl.getText() + ". Connection: " + vpnConnectionId + ". Ожидаю статус подключения.");
runtimeStatus.setText("Запрашиваю статус... " + runtimeStatusText());
runtimeStatus.postDelayed(() -> {
String state = runtimePrefs.getString("state", "");
boolean runtimeActive = isVpnRuntimeActive();
if (!isSystemVpnActive()) {
if (runtimeActive) {
status.setText("VPN runtime активен, рабочий канал поднят. Android еще обновляет системный статус.");
} else if ("stopped".equals(state) || "revoked".equals(state) || "error".equals(state)) {
status.setText("VPN не включился: " + runtimePrefs.getString("message", "Android остановил VPN-сервис") + ".");
} else if ("starting".equals(state) || "tunnel".equals(state) || "relay_selected".equals(state) || "relay".equals(state) || "relay_reset".equals(state)) {
status.setText("VPN запускается. Android еще применяет туннель, ожидаю рабочий канал.");
} else {
status.setText("VPN еще не активен в Android. Проверьте системный запрос разрешения VPN.");
}
} else {
status.setText("VPN включен Android. Версия " + APP_VERSION + ".");
}
runtimeStatus.setText(runtimeStatusText());
}, 2500);
}
private void scheduleRuntimeStatusRefresh() {
runtimeStatus.postDelayed(() -> {
runtimeStatus.setText(runtimeStatusText());
scheduleRuntimeStatusRefresh();
}, 1500);
}
private String runtimeStatusText() {
String state = runtimePrefs.getString("state", "нет данных");
String message = runtimePrefs.getString("message", "");
long updatedAt = runtimePrefs.getLong("updated_at", 0);
long read = runtimePrefs.getLong("uplink_read", 0);
long sent = runtimePrefs.getLong("uplink_sent", 0);
long down = runtimePrefs.getLong("downlink_received", 0);
long errors = runtimePrefs.getLong("errors", 0);
long readBytes = runtimePrefs.getLong("uplink_read_bytes", 0);
long sentBytes = runtimePrefs.getLong("uplink_sent_bytes", 0);
long downBytes = runtimePrefs.getLong("downlink_received_bytes", 0);
long droppedRead = runtimePrefs.getLong("uplink_dropped_packets", 0);
long droppedDown = runtimePrefs.getLong("downlink_dropped_packets", 0);
long bypassControl = runtimePrefs.getLong("uplink_bypassed_control_packets", 0);
long sourceMismatch = runtimePrefs.getLong("uplink_source_mismatch_packets", 0);
long destinationMismatch = runtimePrefs.getLong("downlink_destination_mismatch_packets", 0);
float uplinkReadMbps = runtimePrefs.getFloat("uplink_read_mbps", 0f);
float uplinkSentMbps = runtimePrefs.getFloat("uplink_sent_mbps", 0f);
float downlinkMbps = runtimePrefs.getFloat("downlink_received_mbps", 0f);
float uplinkReadPps = runtimePrefs.getFloat("uplink_read_pps", 0f);
float uplinkSentPps = runtimePrefs.getFloat("uplink_sent_pps", 0f);
float downlinkPps = runtimePrefs.getFloat("downlink_received_pps", 0f);
int workerCount = runtimePrefs.getInt("uplink_worker_count", 0);
int queueDepthTotal = runtimePrefs.getInt("uplink_queue_depth_total", 0);
int queueDepthMax = runtimePrefs.getInt("uplink_queue_depth_max", 0);
String queueDepths = runtimePrefs.getString("uplink_queue_depths", "");
long queue0Drops = runtimePrefs.getLong("uplink_queue_0_drops", 0);
long queue1Drops = runtimePrefs.getLong("uplink_queue_1_drops", 0);
long queue2Drops = runtimePrefs.getLong("uplink_queue_2_drops", 0);
long queue3Drops = runtimePrefs.getLong("uplink_queue_3_drops", 0);
long queue0Offers = runtimePrefs.getLong("uplink_queue_0_offers", 0);
long queue1Offers = runtimePrefs.getLong("uplink_queue_1_offers", 0);
long queue2Offers = runtimePrefs.getLong("uplink_queue_2_offers", 0);
long queue3Offers = runtimePrefs.getLong("uplink_queue_3_offers", 0);
long sender0Packets = runtimePrefs.getLong("uplink_sender_worker_packets_0", 0);
long sender1Packets = runtimePrefs.getLong("uplink_sender_worker_packets_1", 0);
long sender2Packets = runtimePrefs.getLong("uplink_sender_worker_packets_2", 0);
long sender3Packets = runtimePrefs.getLong("uplink_sender_worker_packets_3", 0);
long sender0Errors = runtimePrefs.getLong("uplink_sender_worker_errors_0", 0);
long sender1Errors = runtimePrefs.getLong("uplink_sender_worker_errors_1", 0);
long sender2Errors = runtimePrefs.getLong("uplink_sender_worker_errors_2", 0);
long sender3Errors = runtimePrefs.getLong("uplink_sender_worker_errors_3", 0);
String age = updatedAt <= 0 ? "никогда" : ((System.currentTimeMillis() - updatedAt) / 1000) + " сек назад";
boolean osVpnActive = isSystemVpnActive();
String routes = runtimePrefs.getString("routes", "");
String dnsServers = runtimePrefs.getString("dns_servers", "");
String profileRelayUrl = runtimePrefs.getString("packet_relay_profile_base_url", "");
String activeRelayUrl = runtimePrefs.getString("packet_relay_active_base_url", "");
String relayCandidates = runtimePrefs.getString("packet_relay_candidate_urls", "");
boolean forceFullTunnelRuntime = false;
boolean fastPathEnabled = false;
try {
forceFullTunnelRuntime = runtimePrefs.getBoolean("force_full_tunnel", false);
} catch (Exception ignored) {
}
try {
fastPathEnabled = runtimePrefs.getBoolean("fast_path_enabled", forceFullTunnelRuntime);
} catch (Exception ignored) {
}
boolean staleState = updatedAt > 0 && (System.currentTimeMillis() - updatedAt) > 12_000;
boolean runtimeActive = isVpnRuntimeActive();
if (!osVpnActive && !runtimeActive && ("running".equals(state) || "tunnel".equals(state) || "relay".equals(state) || "relay_reset".equals(state))) {
state = "stale_no_os_vpn";
message = "Сервис говорит об активном состоянии, но Android VPN-интерфейс не активен. Проверьте разрешения/ручной запуск.";
staleState = false;
}
return "Диагностика: " + state
+ "\n" + message
+ "\nOS VPN: " + (osVpnActive ? "активен" : (runtimeActive ? "runtime активен" : "неактивен"))
+ "\n" + (staleState ? "статус устарел" : "статус актуален")
+ "\nread/sent/down: " + read + "/" + sent + "/" + down
+ "\nerrors/drops: " + errors + "/" + (droppedRead + droppedDown)
+ "\ncontrol bypass: " + bypassControl
+ "\naddress mismatch (up/down): " + sourceMismatch + " / " + destinationMismatch
+ "\nthroughput Mbps: up " + String.format(Locale.US, "%.2f", uplinkSentMbps)
+ " / down " + String.format(Locale.US, "%.2f", downlinkMbps)
+ "\npps: up " + String.format(Locale.US, "%.1f", uplinkSentPps)
+ " / down " + String.format(Locale.US, "%.1f", downlinkPps)
+ "\nDNS выхода: " + (dnsServers.isEmpty() ? "-" : dnsServers)
+ "\nroutes: " + (routes.isEmpty() ? "-" : routes)
+ "\nrelay active: " + (activeRelayUrl.isEmpty() ? "-" : activeRelayUrl)
+ "\nrelay profile: " + (profileRelayUrl.isEmpty() ? "-" : profileRelayUrl)
+ "\nrelay candidates: " + (relayCandidates.isEmpty() ? "-" : relayCandidates)
+ "\nforced_full_tunnel: " + (forceFullTunnelRuntime ? "да" : "нет")
+ "\nfast_path_mode: " + (fastPathEnabled ? "включен" : "выключен")
+ "\nbytes read/sent/down: " + readBytes + "/" + sentBytes + "/" + downBytes
+ "\nworkers: " + workerCount
+ "\nqueue depth total/max: " + queueDepthTotal + " / " + queueDepthMax
+ "\nqueue depths: " + (queueDepths.isEmpty() ? "-" : queueDepths)
+ "\nqueue0 q/s: " + queue0Offers + "/" + queue0Drops
+ " q1 " + queue1Offers + "/" + queue1Drops
+ " q2 " + queue2Offers + "/" + queue2Drops
+ " q3 " + queue3Offers + "/" + queue3Drops
+ "\nsender pkt/err: w0 " + sender0Packets + "/" + sender0Errors
+ " w1 " + sender1Packets + "/" + sender1Errors
+ " w2 " + sender2Packets + "/" + sender2Errors
+ " w3 " + sender3Packets + "/" + sender3Errors
+ "\nобновлено: " + age;
}
private void startDiagnosticChannel() {
if (authContext == null || authContext.deviceId.isEmpty()) {
return;
}
RapDiagnosticService.start(this);
}
private boolean isSystemVpnActive() {
try {
ConnectivityManager connectivityManager = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE);
if (connectivityManager == null) {
return false;
}
Network[] networks = connectivityManager.getAllNetworks();
if (networks != null) {
for (Network network : networks) {
NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network);
if (capabilities != null && capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
return true;
}
}
}
return false;
} catch (Exception ignored) {
return false;
}
}
private boolean isVpnRuntimeActive() {
String state = runtimePrefs.getString("state", "");
if ("stopped".equals(state) || "revoked".equals(state) || "error".equals(state)) {
return false;
}
long updatedAt = runtimePrefs.getLong("updated_at", 0);
if (updatedAt <= 0 || (System.currentTimeMillis() - updatedAt) > 15_000) {
return false;
}
String relay = runtimePrefs.getString("packet_relay_active_base_url", "");
long read = runtimePrefs.getLong("uplink_read_total", 0);
long sent = runtimePrefs.getLong("uplink_sent_total", 0);
long down = runtimePrefs.getLong("downlink_received_total", 0);
return !relay.isEmpty() && ("running".equals(state)
|| "relay".equals(state)
|| "relay_reset".equals(state)
|| "downlink".equals(state)
|| "downlink_idle".equals(state)
|| "uplink_sent".equals(state)
|| read > 0 || sent > 0 || down > 0);
}
private String firstConnectionId(String profile) throws Exception {
JSONObject root = new JSONObject(profile);
JSONObject vpnProfile = root.getJSONObject("vpn_client_profile");
JSONArray connections = vpnProfile.getJSONArray("connections");
if (connections.length() == 0) {
throw new IllegalStateException("VPN profile has no connections");
}
String fallback = null;
String waiting = null;
for (int i = 0; i < connections.length(); i++) {
JSONObject connection = connections.optJSONObject(i);
if (connection == null) {
continue;
}
String id = connection.optString("id", "").trim();
if (id.isEmpty()) {
continue;
}
if (fallback == null) {
fallback = id;
}
JSONObject clientConfig = connection.optJSONObject("client_config");
if (clientConfig == null) {
continue;
}
JSONObject fabricRoute = clientConfig.optJSONObject("vpn_fabric_route");
if (fabricRoute == null) {
continue;
}
String status = fabricRoute.optString("status", "").trim().toLowerCase();
if ("planned".equals(status)) {
String entry = fabricRoute.optString("selected_entry_node_id", "").trim();
String exit = fabricRoute.optString("selected_exit_node_id", "").trim();
if (!entry.isEmpty() && !exit.isEmpty()) {
return id;
}
}
if (("connecting".equals(status) || "active".equals(status) || "assigned".equals(status)) && waiting == null) {
waiting = id;
}
}
if (waiting != null) {
return waiting;
}
if (fallback != null) {
return fallback;
}
return connections.getJSONObject(0).getString("id");
}
private String resourcesText(JSONObject payload) throws Exception {
JSONArray resources = payload.optJSONArray("resources");
if (resources == null || resources.length() == 0) {
return "Серверы: доступных ресурсов нет.";
}
StringBuilder text = new StringBuilder("Серверы:\n");
int limit = Math.min(resources.length(), 6);
for (int i = 0; i < limit; i++) {
JSONObject resource = resources.getJSONObject(i);
text.append("")
.append(resource.optString("name", "server"))
.append(" ")
.append(resource.optString("protocol", "rdp"))
.append(" ")
.append(resource.optString("address", ""))
.append('\n');
}
if (resources.length() > limit) {
text.append("и еще ").append(resources.length() - limit).append("...");
}
return text.toString().trim();
}
private int dp(int value) {
return (int) (value * getResources().getDisplayMetrics().density);
}
private String summaryText() {
String deviceId = prefs == null ? "" : prefs.getString(PREF_DEVICE_ID, "");
String connectionId = vpnConnectionId == null || vpnConnectionId.isEmpty()
? (prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, ""))
: vpnConnectionId;
String backendText = backendUrl == null ? "" : backendUrl.getText().toString().trim();
String clusterText = clusterId == null ? "" : clusterId.getText().toString().trim();
String organizationText = organizationId == null ? "" : organizationId.getText().toString().trim();
String exitNode = selectedExitNodeId();
String profileDNS = profileDNSServersText();
return "Версия: " + APP_VERSION
+ "\nКластер: " + (clusterText.isEmpty() ? "не задан" : clusterText)
+ "\nОрганизация: " + (organizationText.isEmpty() ? "не задана" : organizationText)
+ "\nТочка входа: автоматическая (из настроек кластера)"
+ "\nТочка выхода: " + (exitNode.isEmpty() ? "не выбрана (по умолчанию)" : exitNode)
+ "\nDNS выхода: " + (profileDNS.isEmpty() ? "будет получен из профиля" : profileDNS)
+ "\nBackend: " + (backendText.isEmpty() ? "не задан" : backendText)
+ "\nТрафик: " + (prefs.getBoolean(PREF_FORCE_FULL_TUNNEL, true) ? "весь через VPN" : "по профилю")
+ "\nDevice: " + (deviceId.isEmpty() ? "нет" : deviceId)
+ "\nConnection: " + (connectionId.isEmpty() ? "нет" : connectionId);
}
private String profileDNSServersText() {
if (profileJson == null || profileJson.trim().isEmpty()) {
return runtimePrefs == null ? "" : runtimePrefs.getString("dns_servers", "");
}
try {
JSONObject root = new JSONObject(profileJson);
JSONObject vpnProfile = root.optJSONObject("vpn_client_profile");
JSONArray connections = vpnProfile == null ? null : vpnProfile.optJSONArray("connections");
if (connections == null || connections.length() == 0) {
return "";
}
String preferredConnection = vpnConnectionId == null || vpnConnectionId.isEmpty()
? (prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, ""))
: vpnConnectionId;
JSONObject selected = null;
for (int i = 0; i < connections.length(); i++) {
JSONObject candidate = connections.optJSONObject(i);
if (candidate == null) {
continue;
}
if (!preferredConnection.isEmpty() && preferredConnection.equals(candidate.optString("id", ""))) {
selected = candidate;
break;
}
if (selected == null) {
selected = candidate;
}
}
JSONObject clientConfig = selected == null ? null : selected.optJSONObject("client_config");
JSONArray dns = clientConfig == null ? null : clientConfig.optJSONArray("dns_servers");
return joinJSONArray(dns);
} catch (Exception ignored) {
return "";
}
}
private String joinJSONArray(JSONArray values) {
if (values == null || values.length() == 0) {
return "";
}
StringBuilder out = new StringBuilder();
for (int i = 0; i < values.length(); i++) {
String value = values.optString(i, "").trim();
if (value.isEmpty()) {
continue;
}
if (out.length() > 0) {
out.append(",");
}
out.append(value);
}
return out.toString();
}
private String preferredBackendUrl() {
String saved = prefs.getString("backend_url", DEFAULT_BACKEND_URL);
String normalized = normalizeBackendUrl(saved);
if (!normalized.equals(saved == null ? "" : saved.trim())) {
prefs.edit().putString("backend_url", normalized).apply();
}
return normalized;
}
private void saveSettings() {
String normalizedBackend = normalizeBackendUrl(backendUrl.getText().toString());
if (!normalizedBackend.equals(backendUrl.getText().toString().trim())) {
backendUrl.setText(normalizedBackend);
}
normalizeAndPersistDefaults();
if (clusterId.getText().toString().trim().isEmpty()) {
clusterId.setText(DEFAULT_CLUSTER_ID);
}
if (organizationId.getText().toString().trim().isEmpty()) {
organizationId.setText(DEFAULT_ORGANIZATION_ID);
}
prefs.edit()
.putString("backend_url", normalizedBackend)
.putString("cluster_id", clusterId.getText().toString())
.putString("organization_id", organizationId.getText().toString())
.putString("email", email.getText().toString())
.apply();
}
private void normalizeAndPersistDefaults() {
String normalizedBackend = normalizeBackendUrl(backendUrl.getText().toString());
if (normalizedBackend.isEmpty()) {
backendUrl.setText(DEFAULT_BACKEND_URL);
}
if (clusterId.getText().toString().trim().isEmpty()) {
clusterId.setText(DEFAULT_CLUSTER_ID);
}
if (organizationId.getText().toString().trim().isEmpty()) {
organizationId.setText(DEFAULT_ORGANIZATION_ID);
}
}
private String normalizeBackendUrl(String value) {
String candidate = value == null ? "" : value.trim().replaceAll("/+$", "");
if (candidate.isEmpty()) {
return DEFAULT_BACKEND_URL;
}
return candidate;
}
private String selectedExitNodeId() {
String configured = prefs == null ? "" : prefs.getString(PREF_SELECTED_EXIT_NODE_ID, "");
return normalizeSelectedExitNodeId(configured);
}
private String normalizeSelectedExitNodeId(String value) {
String candidate = value == null ? "" : value.trim();
if (candidate.isEmpty()) {
return "";
}
if (candidate.matches("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")) {
return candidate;
}
if (candidate.matches("^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$")) {
return candidate;
}
return "";
}
private RapApiClient.AuthContext authenticate(RapApiClient client) throws Exception {
String savedRefresh = savedRefreshToken();
if (!savedRefresh.isEmpty()) {
try {
RapApiClient.AuthContext refreshed = client.refresh(savedRefresh);
saveAuthContext(refreshed);
return refreshed;
} catch (Exception ignored) {
clearSavedAuth(false);
}
}
String passwordValue = password.getText().toString().trim();
if (passwordValue.isEmpty()) {
throw new IllegalStateException("Сессия устройства истекла или отозвана. Введите пароль один раз, дальше ключи обновятся автоматически.");
}
RapApiClient.AuthContext loggedIn = client.login(email.getText().toString().trim(), passwordValue, deviceFingerprint());
saveAuthContext(loggedIn);
return loggedIn;
}
private String resolveOrganizationId(RapApiClient client, String userId) throws Exception {
JSONObject payload = client.organizations(userId);
JSONArray organizations = payload.optJSONArray("organizations");
if (organizations == null || organizations.length() == 0) {
throw new IllegalStateException("У пользователя нет активной организации.");
}
String configured = organizationId.getText().toString().trim();
JSONObject fallback = null;
for (int i = 0; i < organizations.length(); i++) {
JSONObject item = organizations.optJSONObject(i);
if (item == null) {
continue;
}
String id = item.optString("id", "");
String name = item.optString("name", "");
String slug = item.optString("slug", "");
if (!configured.isEmpty() && configured.equals(id)) {
return configured;
}
if (fallback == null || "HOME".equalsIgnoreCase(name) || "home".equalsIgnoreCase(slug)) {
fallback = item;
}
}
String selected = fallback != null ? fallback.optString("id", "") : "";
if (selected.isEmpty()) {
throw new IllegalStateException("Не удалось выбрать организацию пользователя.");
}
runOnUiThread(() -> {
organizationId.setText(selected);
saveSettings();
});
return selected;
}
private void saveAuthContext(RapApiClient.AuthContext context) throws Exception {
secureTokens.put(PREF_REFRESH_TOKEN, context.refreshToken);
prefs.edit()
.putString(PREF_USER_ID, context.userId)
.putString(PREF_DEVICE_ID, context.deviceId)
.putString(PREF_REFRESH_EXPIRES_AT, context.refreshTokenExpiresAt)
.putString(PREF_PROFILE_JSON, profileJson)
.putString(PREF_VPN_CONNECTION_ID, vpnConnectionId)
.apply();
}
private void saveProfileState() {
prefs.edit()
.putString(PREF_PROFILE_JSON, profileJson)
.putString(PREF_VPN_CONNECTION_ID, vpnConnectionId)
.apply();
}
private void restoreAuthContext() {
String userId = prefs.getString(PREF_USER_ID, "");
String deviceId = prefs.getString(PREF_DEVICE_ID, "");
if (!userId.isEmpty() && !deviceId.isEmpty()) {
authContext = new RapApiClient.AuthContext(
userId,
deviceId,
"",
"",
secureTokens.get(PREF_REFRESH_TOKEN),
prefs.getString(PREF_REFRESH_EXPIRES_AT, ""));
}
}
private void clearSavedAuth(boolean clearProfile) {
secureTokens.remove(PREF_REFRESH_TOKEN);
SharedPreferences.Editor editor = prefs.edit()
.remove(PREF_REFRESH_EXPIRES_AT)
.remove(PREF_USER_ID)
.remove(PREF_DEVICE_ID);
if (clearProfile) {
editor.remove(PREF_PROFILE_JSON).remove(PREF_VPN_CONNECTION_ID);
profileJson = "";
vpnConnectionId = "";
}
editor.apply();
authContext = null;
}
private String savedRefreshToken() {
String token = secureTokens.get(PREF_REFRESH_TOKEN);
if (!token.isEmpty()) {
return token;
}
String legacyToken = prefs.getString(PREF_REFRESH_TOKEN, "");
if (!legacyToken.isEmpty()) {
try {
secureTokens.put(PREF_REFRESH_TOKEN, legacyToken);
prefs.edit().remove(PREF_REFRESH_TOKEN).apply();
} catch (Exception ignored) {
}
}
return legacyToken;
}
private String deviceFingerprint() {
String existing = prefs.getString(PREF_DEVICE_FINGERPRINT, "");
if (!existing.isEmpty()) {
return existing;
}
String generated = "android-" + java.util.UUID.randomUUID();
prefs.edit().putString(PREF_DEVICE_FINGERPRINT, generated).apply();
return generated;
}
private void showSettingsDialog() {
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
int pad = dp(12);
form.setPadding(pad, pad, pad, pad);
EditText backendDraft = field("Backend URL", backendUrl.getText().toString());
EditText clusterDraft = field("Cluster ID", clusterId.getText().toString());
EditText organizationDraft = field("Organization ID", organizationId.getText().toString());
EditText emailDraft = field("Email", email.getText().toString());
EditText passwordDraft = field("Password", password.getText().toString());
passwordDraft.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
passwordDraft.setHint("Password (не сохраняется)");
EditText selectedExitDraft = field(
"Точка выхода (Node ID, например ifcm)",
prefs.getString(PREF_SELECTED_EXIT_NODE_ID, ""));
CheckBox showPassword = new CheckBox(this);
showPassword.setText("Показать пароль");
showPassword.setTextColor(0xff111111);
showPassword.setOnCheckedChangeListener((buttonView, isChecked) -> {
passwordDraft.setInputType(InputType.TYPE_CLASS_TEXT | (isChecked
? InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
: InputType.TYPE_TEXT_VARIATION_PASSWORD));
passwordDraft.setSelection(passwordDraft.getText().length());
});
CheckBox forceFullTunnel = new CheckBox(this);
forceFullTunnel.setText("Полный маршрут через VPN");
forceFullTunnel.setChecked(prefs.getBoolean(PREF_FORCE_FULL_TUNNEL, true));
forceFullTunnel.setTextColor(0xff111111);
form.addView(backendDraft);
form.addView(clusterDraft);
form.addView(organizationDraft);
form.addView(emailDraft);
form.addView(passwordDraft);
form.addView(selectedExitDraft);
form.addView(showPassword);
form.addView(forceFullTunnel);
new AlertDialog.Builder(this)
.setTitle("Настройки подключения")
.setView(form)
.setPositiveButton("Сохранить", (dialog, which) -> {
backendUrl.setText(backendDraft.getText().toString());
clusterId.setText(clusterDraft.getText().toString());
organizationId.setText(organizationDraft.getText().toString());
email.setText(emailDraft.getText().toString());
password.setText(passwordDraft.getText().toString());
String normalizedExit = normalizeSelectedExitNodeId(selectedExitDraft.getText().toString());
prefs.edit()
.putString(PREF_SELECTED_EXIT_NODE_ID, normalizedExit)
.apply();
if (!normalizedExit.equals(selectedExitDraft.getText().toString().trim())) {
status.setText("Точка выхода очищена: значение было не похоже на Node ID/alias.");
}
prefs.edit().putBoolean(PREF_FORCE_FULL_TUNNEL, forceFullTunnel.isChecked()).apply();
saveSettings();
profileSummary.setText(summaryText());
})
.setNeutralButton("Забыть устройство", (dialog, which) -> {
clearSavedAuth(true);
status.setText("Устройство забыто. Для следующего входа нужен пароль.");
})
.setNegativeButton("Отмена", null)
.show();
}
private String friendlyError(Exception ex) {
String message = ex.getMessage();
if (message == null || message.trim().isEmpty()) {
return "неизвестная ошибка";
}
if (message.contains("auth.invalid_credentials") || message.contains("Неверный логин")) {
int passwordLength = password.getText() == null ? 0 : password.getText().toString().length();
return "Неверный логин или пароль. Проверьте раскладку и спецсимволы. Длина введенного пароля: " + passwordLength + ".";
}
if (message.contains("auth.invalid_refresh_token") || message.contains("invalid refresh token")) {
return "Сессия устройства истекла. Введите пароль один раз, дальше ключи обновятся автоматически.";
}
return message;
}
private void showServerPicker() {
if (lastResources.length() == 0) {
loadProfile();
status.setText("Загружаю список серверов...");
return;
}
String[] labels = new String[lastResources.length()];
for (int i = 0; i < lastResources.length(); i++) {
JSONObject resource = lastResources.optJSONObject(i);
labels[i] = resource == null
? "server"
: resource.optString("name", "server") + " " + resource.optString("address", "");
}
new AlertDialog.Builder(this)
.setTitle("Удаленный сервер")
.setItems(labels, (dialog, which) -> startRemoteDesktop(which))
.show();
}
private void startRemoteDesktop(int index) {
JSONObject resource = lastResources.optJSONObject(index);
if (resource == null) {
return;
}
if (authContext == null || authContext.userId.isEmpty() || authContext.deviceId.isEmpty()) {
loadProfile();
status.setText("Профиль обновляется. Повторите открытие сервера.");
return;
}
status.setText("Открываю " + resource.optString("name", "сервер") + "...");
new Thread(() -> {
try {
RapApiClient client = new RapApiClient(backendUrl.getText().toString(), this);
JSONObject result = client.startSession(resource.getString("id"), authContext.userId, authContext.deviceId);
Intent intent = new Intent(this, RdpActivity.class);
intent.putExtra(RdpActivity.EXTRA_SESSION_RESULT, result.toString());
intent.putExtra(RdpActivity.EXTRA_GATEWAY_URL, gatewayUrl());
intent.putExtra(RdpActivity.EXTRA_RESOURCE_NAME, resource.optString("name", "Remote Desktop"));
runOnUiThread(() -> {
status.setText("Сессия создана.");
startActivity(intent);
});
} catch (Exception ex) {
runOnUiThread(() -> status.setText("Ошибка RDP: " + ex.getMessage()));
}
}).start();
}
private String gatewayUrl() {
String api = backendUrl.getText().toString().trim();
String gateway = api.replace("https://", "wss://").replace("http://", "ws://");
if (gateway.endsWith("/")) {
gateway = gateway.substring(0, gateway.length() - 1);
}
return gateway + "/gateway/ws";
}
}
@@ -0,0 +1,630 @@
package su.cin.rapvpn;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.VpnService;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Dispatcher;
import okhttp3.ConnectionPool;
import okhttp3.Dns;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
import javax.net.SocketFactory;
final class RapApiClient {
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
private static final MediaType OCTET_STREAM = MediaType.get("application/octet-stream");
private static final int MAX_PACKET_BATCH_PACKETS = 512;
private static final int MAX_PACKET_BATCH_BYTES = 512 * 1024;
private static final int MAX_SINGLE_PACKET_BYTES = 65535;
private static final int MAX_BATCH_HEADER_BYTES = 4;
private final String baseUrl;
private final OkHttpClient httpClient;
private final String networkMode;
private final FabricServiceChannel fabricServiceChannel;
RapApiClient(String baseUrl) {
this(baseUrl, (Context) null);
}
RapApiClient(String baseUrl, Context context) {
this.baseUrl = trimRight(baseUrl);
this.fabricServiceChannel = new FabricServiceChannel();
OkHttpClient.Builder builder = new OkHttpClient.Builder();
// Regular app and diagnostic requests should use Android's default
// routing. Some devices reject binding app sockets to a specific
// Network with EACCES, which must not block login/profile refresh.
this.networkMode = context == null ? "default_network" : "default_network_context";
builder.dns(new BackendPinnedDns(baseUrl));
builder.connectTimeout(5, TimeUnit.SECONDS);
builder.writeTimeout(12, TimeUnit.SECONDS);
builder.readTimeout(12, TimeUnit.SECONDS);
builder.callTimeout(15, TimeUnit.SECONDS);
builder.retryOnConnectionFailure(true);
Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(64);
dispatcher.setMaxRequestsPerHost(32);
builder.dispatcher(dispatcher);
builder.connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES));
this.httpClient = builder.build();
}
RapApiClient(String baseUrl, Context context, boolean preferUnderlyingNetwork) {
this.baseUrl = trimRight(baseUrl);
this.fabricServiceChannel = new FabricServiceChannel();
OkHttpClient.Builder builder = new OkHttpClient.Builder();
String mode = context == null ? "default_network" : "default_network_context";
if (preferUnderlyingNetwork && context != null) {
SocketFactory socketFactory = underlyingSocketFactory(context);
if (socketFactory != null) {
builder.socketFactory(socketFactory);
mode = "underlying_network_context";
}
}
this.networkMode = mode;
builder.dns(new BackendPinnedDns(baseUrl));
builder.connectTimeout(3, TimeUnit.SECONDS);
builder.writeTimeout(6, TimeUnit.SECONDS);
builder.readTimeout(6, TimeUnit.SECONDS);
builder.callTimeout(8, TimeUnit.SECONDS);
builder.retryOnConnectionFailure(true);
Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(64);
dispatcher.setMaxRequestsPerHost(32);
builder.dispatcher(dispatcher);
builder.connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES));
this.httpClient = builder.build();
}
RapApiClient(String baseUrl, VpnService vpnService) {
this(baseUrl, vpnService, new FabricServiceChannel());
}
RapApiClient(String baseUrl, VpnService vpnService, FabricServiceChannel fabricServiceChannel) {
this.baseUrl = trimRight(baseUrl);
this.fabricServiceChannel = fabricServiceChannel == null ? new FabricServiceChannel() : fabricServiceChannel;
OkHttpClient.Builder builder = new OkHttpClient.Builder();
if (vpnService != null) {
builder.socketFactory(new ProtectedSocketFactory(vpnService));
builder.dns(new BackendPinnedDns(baseUrl));
this.networkMode = "protected_socket";
} else {
this.networkMode = "default_network";
}
builder.connectTimeout(3, TimeUnit.SECONDS);
builder.writeTimeout(8, TimeUnit.SECONDS);
builder.readTimeout(8, TimeUnit.SECONDS);
builder.callTimeout(10, TimeUnit.SECONDS);
builder.retryOnConnectionFailure(false);
Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(64);
dispatcher.setMaxRequestsPerHost(32);
builder.dispatcher(dispatcher);
builder.connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES));
this.httpClient = builder.build();
}
RapApiClient(String baseUrl, Network network) {
this.baseUrl = trimRight(baseUrl);
this.fabricServiceChannel = new FabricServiceChannel();
OkHttpClient.Builder builder = new OkHttpClient.Builder();
if (network != null) {
builder.socketFactory(network.getSocketFactory());
builder.dns(hostname -> {
InetAddress[] addresses = network.getAllByName(hostname);
if (addresses == null || addresses.length == 0) {
throw new UnknownHostException(hostname);
}
List<InetAddress> out = new ArrayList<>();
Collections.addAll(out, addresses);
return out;
});
this.networkMode = "vpn_network";
} else {
builder.dns(new BackendPinnedDns(baseUrl));
this.networkMode = "default_network";
}
builder.connectTimeout(5, TimeUnit.SECONDS);
builder.writeTimeout(12, TimeUnit.SECONDS);
builder.readTimeout(12, TimeUnit.SECONDS);
builder.callTimeout(15, TimeUnit.SECONDS);
builder.retryOnConnectionFailure(true);
Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(64);
dispatcher.setMaxRequestsPerHost(32);
builder.dispatcher(dispatcher);
builder.connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES));
this.httpClient = builder.build();
}
String networkMode() {
return networkMode;
}
static final class BackendPinnedDns implements Dns {
private static final String VPN_PUBLIC_HOST = "vpn.cin.su";
private static final String VPN_PUBLIC_IPV4 = "94.141.118.222";
private final String backendHost;
BackendPinnedDns(String baseUrl) {
String parsedHost = "";
try {
parsedHost = URI.create(baseUrl == null ? "" : baseUrl).getHost();
} catch (Exception ignored) {
}
backendHost = parsedHost == null ? "" : parsedHost.trim().toLowerCase();
}
@Override
public List<InetAddress> lookup(String hostname) throws UnknownHostException {
String host = hostname == null ? "" : hostname.trim().toLowerCase();
if (!backendHost.isEmpty() && host.equals(backendHost) && VPN_PUBLIC_HOST.equals(host)) {
return Collections.singletonList(InetAddress.getByName(VPN_PUBLIC_IPV4));
}
return Dns.SYSTEM.lookup(hostname);
}
}
private SocketFactory underlyingSocketFactory(Context context) {
ConnectivityManager connectivity = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
if (connectivity == null) {
return null;
}
for (Network network : connectivity.getAllNetworks()) {
NetworkCapabilities capabilities = connectivity.getNetworkCapabilities(network);
if (capabilities == null) {
continue;
}
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
continue;
}
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
continue;
}
return network.getSocketFactory();
}
return null;
}
AuthContext login(String email, String password, String deviceFingerprint) throws Exception {
JSONObject body = new JSONObject();
body.put("email", email);
body.put("password", password);
body.put("device_fingerprint", deviceFingerprint);
body.put("device_label", "RAP Android VPN");
body.put("trust_device", true);
JSONObject response = post("/auth/login", body);
return parseAuthContext(response);
}
AuthContext refresh(String refreshToken) throws Exception {
JSONObject body = new JSONObject();
body.put("refresh_token", refreshToken);
return parseAuthContext(post("/auth/refresh", body));
}
String vpnClientProfile(String clusterId, String organizationId, String userId, String exitNodeId) throws Exception {
String path = "/clusters/" + clusterId + "/vpn/client-profile?organization_id=" + organizationId + "&user_id=" + userId;
if (exitNodeId != null && !exitNodeId.trim().isEmpty()) {
path += "&exit_node_id=" + exitNodeId.trim();
}
return get(path).toString();
}
JSONObject organizations(String userId) throws Exception {
return get("/organizations/?user_id=" + userId);
}
JSONObject resources(String organizationId, String userId) throws Exception {
String path = "/resources/?organization_id=" + organizationId + "&user_id=" + userId;
return get(path);
}
JSONObject startSession(String resourceId, String userId, String deviceId) throws Exception {
JSONObject body = new JSONObject();
body.put("resource_id", resourceId);
body.put("user_id", userId);
body.put("device_id", deviceId);
return post("/sessions/", body);
}
JSONObject reportVPNDiagnosticStatus(String clusterId, String deviceId, JSONObject payload) throws Exception {
return post("/clusters/" + clusterId + "/vpn/client-diagnostics/" + deviceId + "/status", payload);
}
JSONObject nextVPNDiagnosticCommand(String clusterId, String deviceId, int timeoutMs) throws Exception {
byte[] payload = getBytes("/clusters/" + clusterId + "/vpn/client-diagnostics/" + deviceId + "/commands?timeout_ms=" + timeoutMs);
if (payload.length == 0) {
return null;
}
return new JSONObject(new String(payload, StandardCharsets.UTF_8));
}
JSONObject vpnPacketStats(String clusterId, String vpnConnectionId) throws Exception {
return get("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/stats");
}
JSONObject resetVPNPacketQueues(String clusterId, String vpnConnectionId) throws Exception {
return post("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/reset", new JSONObject());
}
void sendClientPacket(String clusterId, String vpnConnectionId, byte[] packet, int length) throws Exception {
postBytes(clientPacketPath(clusterId, vpnConnectionId, ""), packet, length);
}
void sendClientPacketBatch(String clusterId, String vpnConnectionId, List<byte[]> packets) throws Exception {
if (packets == null || packets.isEmpty()) {
return;
}
List<List<byte[]>> chunks = chunkPacketsForBatch(packets);
if (chunks.isEmpty()) {
return;
}
for (List<byte[]> chunk : chunks) {
postBytes(clientPacketPath(clusterId, vpnConnectionId, "?batch=true"), encodePacketBatch(chunk));
}
}
byte[] receiveClientPacket(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception {
try {
return getBytes(clientPacketPath(clusterId, vpnConnectionId, "?timeout_ms=" + timeoutMs));
} catch (InterruptedIOException e) {
return new byte[0];
} catch (IOException e) {
if (e.getMessage() != null && e.getMessage().toLowerCase().contains("timeout")) {
return new byte[0];
}
throw e;
} catch (IllegalStateException e) {
String message = e.getMessage();
if (message != null && (message.contains("HTTP 502") || message.contains("HTTP 503") || message.contains("HTTP 504"))) {
return new byte[0];
}
throw e;
}
}
List<byte[]> receiveClientPacketBatch(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception {
byte[] payload;
try {
payload = getBytes(clientPacketPath(clusterId, vpnConnectionId, "?batch=true&timeout_ms=" + timeoutMs));
if (payload == null || payload.length == 0) {
return new ArrayList<>();
}
if (!isLikelyPacketBatch(payload)) {
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
}
return decodePacketBatch(payload);
} catch (InterruptedIOException e) {
return new ArrayList<>();
} catch (IOException e) {
if (e.getMessage() != null && e.getMessage().toLowerCase().contains("timeout")) {
return new ArrayList<>();
}
throw e;
} catch (IllegalStateException e) {
String message = e.getMessage();
if (message != null && (message.contains("HTTP 502") || message.contains("HTTP 503") || message.contains("HTTP 504"))) {
return new ArrayList<>();
}
throw e;
}
}
private JSONObject get(String path) throws Exception {
Request request = new Request.Builder().url(baseUrl + path).get().build();
return read(request);
}
private JSONObject post(String path, JSONObject body) throws Exception {
Request request = new Request.Builder()
.url(baseUrl + path)
.post(RequestBody.create(body.toString().getBytes(StandardCharsets.UTF_8), JSON))
.build();
return read(request);
}
private byte[] getBytes(String path) throws Exception {
Request.Builder builder = new Request.Builder().url(baseUrl + path).get();
applyFabricHeadersIfNeeded(builder, path);
Request request = builder.build();
try (Response response = httpClient.newCall(request).execute()) {
if (response.code() == 204) {
return new byte[0];
}
if (!response.isSuccessful()) {
throw new IllegalStateException("HTTP " + response.code());
}
ResponseBody body = response.body();
return body == null ? new byte[0] : body.bytes();
}
}
private void postBytes(String path, byte[] packet, int length) throws Exception {
byte[] bodyBytes = new byte[length];
System.arraycopy(packet, 0, bodyBytes, 0, length);
postBytes(path, bodyBytes);
}
private void postBytes(String path, byte[] bodyBytes) throws Exception {
Request.Builder builder = new Request.Builder()
.url(baseUrl + path)
.post(RequestBody.create(bodyBytes, OCTET_STREAM));
applyFabricHeadersIfNeeded(builder, path);
Request request = builder.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IllegalStateException("HTTP " + response.code());
}
}
}
private String clientPacketPath(String clusterId, String vpnConnectionId, String suffix) {
String path = fabricServiceChannel.packetPathForBase(baseUrl, clusterId, vpnConnectionId, false);
if (path.isEmpty()) {
path = "/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets";
}
return path + (suffix == null ? "" : suffix);
}
private void applyFabricHeadersIfNeeded(Request.Builder builder, String path) {
if (path != null && path.contains("/fabric/service-channels/")) {
fabricServiceChannel.applyHeaders(builder);
}
}
private byte[] encodePacketBatch(List<byte[]> packets) {
int total = 0;
for (byte[] packet : packets) {
if (packet != null && packet.length > 0) {
total += 4 + packet.length;
}
}
byte[] out = new byte[total];
int offset = 0;
for (byte[] packet : packets) {
if (packet == null || packet.length == 0) {
continue;
}
int length = packet.length;
out[offset] = (byte) ((length >> 24) & 0xff);
out[offset + 1] = (byte) ((length >> 16) & 0xff);
out[offset + 2] = (byte) ((length >> 8) & 0xff);
out[offset + 3] = (byte) (length & 0xff);
offset += 4;
System.arraycopy(packet, 0, out, offset, length);
offset += length;
}
return out;
}
private JSONObject read(Request request) throws Exception {
try (Response response = httpClient.newCall(request).execute()) {
ResponseBody body = response.body();
String text = body == null ? "" : body.string();
if (!response.isSuccessful()) {
if (response.code() == 401 && text.contains("auth.invalid_credentials")) {
throw new IllegalStateException("Неверный логин или пароль.");
}
if (response.code() == 401 && text.contains("auth.invalid_refresh_token")) {
throw new IllegalStateException("Сессия устройства истекла. Введите пароль один раз.");
}
throw new IllegalStateException("HTTP " + response.code() + ": " + text);
}
return new JSONObject(text);
}
}
private List<byte[]> decodePacketBatch(byte[] payload) {
List<byte[]> packets = new ArrayList<>();
int offset = 0;
while (payload != null && offset + 4 <= payload.length) {
int length = ((payload[offset] & 0xff) << 24)
| ((payload[offset + 1] & 0xff) << 16)
| ((payload[offset + 2] & 0xff) << 8)
| (payload[offset + 3] & 0xff);
offset += 4;
if (length <= 0 || offset + length > payload.length) {
break;
}
byte[] packet = new byte[length];
System.arraycopy(payload, offset, packet, 0, length);
packets.add(packet);
offset += length;
}
return packets;
}
private List<List<byte[]>> chunkPacketsForBatch(List<byte[]> packets) {
List<List<byte[]>> chunks = new ArrayList<>();
List<byte[]> current = new ArrayList<>();
int currentBytes = 0;
boolean hasData = false;
for (byte[] packet : packets) {
if (packet == null || packet.length == 0) {
continue;
}
if (packet.length > MAX_SINGLE_PACKET_BYTES) {
continue;
}
hasData = true;
int projected = currentBytes + MAX_BATCH_HEADER_BYTES + packet.length;
if (!current.isEmpty() && (current.size() >= MAX_PACKET_BATCH_PACKETS || projected > MAX_PACKET_BATCH_BYTES)) {
chunks.add(current);
current = new ArrayList<>();
currentBytes = 0;
}
current.add(packet);
currentBytes = projected;
}
if (!hasData) {
return chunks;
}
if (!current.isEmpty()) {
chunks.add(current);
}
return chunks;
}
private boolean isLikelyPacketBatch(byte[] payload) {
if (payload == null || payload.length < MAX_BATCH_HEADER_BYTES) {
return false;
}
int offset = 0;
int consumed = 0;
while (offset + MAX_BATCH_HEADER_BYTES <= payload.length) {
int length = ((payload[offset] & 0xff) << 24)
| ((payload[offset + 1] & 0xff) << 16)
| ((payload[offset + 2] & 0xff) << 8)
| (payload[offset + 3] & 0xff);
offset += MAX_BATCH_HEADER_BYTES;
if (length <= 0 || length > MAX_SINGLE_PACKET_BYTES) {
return false;
}
if (offset + length > payload.length) {
return false;
}
offset += length;
consumed++;
if (consumed > MAX_PACKET_BATCH_PACKETS) {
return false;
}
}
return offset == payload.length && consumed > 0;
}
private List<byte[]> receiveSinglePacketAsBatch(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception {
byte[] payload = receiveClientPacket(clusterId, vpnConnectionId, timeoutMs);
if (payload == null || payload.length == 0) {
return new ArrayList<>();
}
return new ArrayList<>(Collections.singletonList(payload));
}
private AuthContext parseAuthContext(JSONObject response) throws Exception {
JSONObject user = response.getJSONObject("user");
String userId = user.optString("id", "");
if (userId.isEmpty()) {
userId = user.optString("ID", "");
}
JSONObject device = response.optJSONObject("device");
String deviceId = device != null ? device.optString("id", "") : "";
if (deviceId.isEmpty() && device != null) {
deviceId = device.optString("ID", "");
}
JSONObject tokens = response.optJSONObject("tokens");
String accessToken = tokens != null ? tokens.optString("access_token", "") : "";
String accessExpiresAt = tokens != null ? tokens.optString("access_token_expires_at", "") : "";
String refreshToken = tokens != null ? tokens.optString("refresh_token", "") : "";
String refreshExpiresAt = tokens != null ? tokens.optString("refresh_token_expires_at", "") : "";
return new AuthContext(userId, deviceId, accessToken, accessExpiresAt, refreshToken, refreshExpiresAt);
}
private String trimRight(String value) {
while (value.endsWith("/")) {
value = value.substring(0, value.length() - 1);
}
return value;
}
static final class ProtectedSocketFactory extends SocketFactory {
private final SocketFactory delegate = SocketFactory.getDefault();
private final VpnService vpnService;
ProtectedSocketFactory(VpnService vpnService) {
this.vpnService = vpnService;
}
@Override
public Socket createSocket() throws IOException {
Socket socket = delegate.createSocket();
socket.bind(null);
return protect(socket);
}
@Override
public Socket createSocket(String host, int port) throws IOException {
Socket socket = createSocket();
socket.connect(new InetSocketAddress(host, port));
return socket;
}
@Override
public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException {
Socket socket = delegate.createSocket();
socket.bind(new InetSocketAddress(localHost, localPort));
protect(socket);
socket.connect(new InetSocketAddress(host, port));
return socket;
}
@Override
public Socket createSocket(InetAddress host, int port) throws IOException {
Socket socket = createSocket();
socket.connect(new InetSocketAddress(host, port));
return socket;
}
@Override
public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
Socket socket = delegate.createSocket();
socket.bind(new InetSocketAddress(localAddress, localPort));
protect(socket);
socket.connect(new InetSocketAddress(address, port));
return socket;
}
private Socket protect(Socket socket) throws IOException {
if (!vpnService.protect(socket)) {
try {
socket.close();
} catch (IOException ignored) {
}
throw new IOException("protect control-plane socket failed");
}
return socket;
}
}
static final class AuthContext {
final String userId;
final String deviceId;
final String accessToken;
final String accessTokenExpiresAt;
final String refreshToken;
final String refreshTokenExpiresAt;
AuthContext(String userId, String deviceId, String accessToken, String accessTokenExpiresAt, String refreshToken, String refreshTokenExpiresAt) {
this.userId = userId;
this.deviceId = deviceId;
this.accessToken = accessToken;
this.accessTokenExpiresAt = accessTokenExpiresAt;
this.refreshToken = refreshToken;
this.refreshTokenExpiresAt = refreshTokenExpiresAt;
}
}
}
@@ -0,0 +1,54 @@
package su.cin.rapvpn;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.VpnService;
import android.os.Build;
public final class RapAutostartReceiver extends BroadcastReceiver {
private static final String PREFS = "rap-vpn";
private static final String PREF_PROFILE_JSON = "profile_json";
private static final String PREF_BACKEND_URL = "backend_url";
private static final String PREF_CLUSTER_ID = "cluster_id";
private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id";
private static final String PREF_MANUAL_STOPPED = "manual_stopped";
@Override
public void onReceive(Context context, Intent intent) {
if (context == null || intent == null) {
return;
}
String action = intent.getAction();
if (!Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)
&& !Intent.ACTION_BOOT_COMPLETED.equals(action)) {
return;
}
RapDiagnosticService.start(context);
SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
if (prefs.getBoolean(PREF_MANUAL_STOPPED, false)) {
return;
}
String profile = prefs.getString(PREF_PROFILE_JSON, "");
String backendUrl = prefs.getString(PREF_BACKEND_URL, "");
String clusterId = prefs.getString(PREF_CLUSTER_ID, "");
String vpnConnectionId = prefs.getString(PREF_VPN_CONNECTION_ID, "");
if (profile.isEmpty() || backendUrl.isEmpty() || clusterId.isEmpty() || vpnConnectionId.isEmpty()) {
return;
}
if (VpnService.prepare(context) != null) {
return;
}
Intent service = new Intent(context, RapVpnService.class);
service.putExtra("profile_json", profile);
service.putExtra("backend_url", backendUrl);
service.putExtra("cluster_id", clusterId);
service.putExtra("vpn_connection_id", vpnConnectionId);
if (Build.VERSION.SDK_INT >= 26) {
context.startForegroundService(service);
} else {
context.startService(service);
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,209 @@
package su.cin.rapvpn;
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Bundle;
import android.util.Base64;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import org.json.JSONObject;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
public class RdpActivity extends Activity {
static final String EXTRA_SESSION_RESULT = "session_result";
static final String EXTRA_GATEWAY_URL = "gateway_url";
static final String EXTRA_RESOURCE_NAME = "resource_name";
private final OkHttpClient http = new OkHttpClient();
private ImageView desktop;
private TextView overlay;
private WebSocket webSocket;
private int desktopWidth = 1;
private int desktopHeight = 1;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
FrameLayout root = new FrameLayout(this);
root.setBackgroundColor(0xff05090c);
desktop = new ImageView(this);
desktop.setScaleType(ImageView.ScaleType.FIT_CENTER);
desktop.setBackgroundColor(0xff05090c);
desktop.setOnTouchListener((view, event) -> {
sendTouch(event);
return true;
});
overlay = new TextView(this);
overlay.setTextColor(0xffffffff);
overlay.setTextSize(14);
overlay.setBackgroundColor(0x66000000);
overlay.setPadding(14, 10, 14, 10);
overlay.setText("Подключение...");
root.addView(desktop, new FrameLayout.LayoutParams(-1, -1));
root.addView(overlay, new FrameLayout.LayoutParams(-2, -2));
setContentView(root);
connect();
}
@Override
protected void onDestroy() {
if (webSocket != null) {
webSocket.close(1000, "activity closed");
}
super.onDestroy();
}
private void connect() {
try {
JSONObject result = new JSONObject(getIntent().getStringExtra(EXTRA_SESSION_RESULT));
JSONObject token = result.getJSONObject("attach_token");
String attachToken = token.getString("token");
String gatewayUrl = getIntent().getStringExtra(EXTRA_GATEWAY_URL);
String url = gatewayUrl + "?attach_token=" + attachToken;
runOnUiThread(() -> overlay.setText(getIntent().getStringExtra(EXTRA_RESOURCE_NAME)));
Request request = new Request.Builder().url(url).build();
webSocket = http.newWebSocket(request, new WebSocketListener() {
@Override
public void onOpen(WebSocket webSocket, Response response) {
runOnUiThread(() -> overlay.setText("Подключено"));
}
@Override
public void onMessage(WebSocket webSocket, String text) {
handleEnvelope(text);
}
@Override
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
runOnUiThread(() -> overlay.setText("Ошибка: " + t.getMessage()));
}
@Override
public void onClosed(WebSocket webSocket, int code, String reason) {
runOnUiThread(() -> overlay.setText("Отключено"));
}
});
} catch (Exception ex) {
overlay.setText("Ошибка запуска: " + ex.getMessage());
}
}
private void handleEnvelope(String text) {
try {
JSONObject envelope = new JSONObject(text);
String type = envelope.optString("type");
if ("session.state".equals(type)) {
JSONObject payload = envelope.optJSONObject("payload");
String state = payload == null ? "" : payload.optString("state", "");
if (!state.isEmpty() && !"active".equals(state)) {
runOnUiThread(() -> overlay.setText("Сессия: " + state));
}
return;
}
if (!"session.frame".equals(type)) {
return;
}
JSONObject payload = envelope.optJSONObject("payload");
if (payload == null) {
return;
}
String frameData = payload.optString("frame_data", "");
int width = payload.optInt("frame_width", payload.optInt("desktop_width", 0));
int height = payload.optInt("frame_height", payload.optInt("desktop_height", 0));
byte[] bytes = Base64.decode(frameData, Base64.DEFAULT);
Bitmap bitmap = decodeFrame(bytes, width, height, payload.optString("frame_format", ""));
if (bitmap != null) {
desktopWidth = Math.max(1, width);
desktopHeight = Math.max(1, height);
runOnUiThread(() -> {
desktop.setImageBitmap(bitmap);
overlay.setText("");
});
}
} catch (Exception ex) {
runOnUiThread(() -> overlay.setText("Кадр: " + ex.getMessage()));
}
}
private Bitmap decodeFrame(byte[] bytes, int width, int height, String format) {
Bitmap compressed = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
if (compressed != null) {
return compressed;
}
if (width <= 0 || height <= 0 || bytes.length < width * height * 4) {
return null;
}
int[] colors = new int[width * height];
ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
for (int i = 0; i < colors.length; i++) {
int b = buffer.get() & 0xff;
int g = buffer.get() & 0xff;
int r = buffer.get() & 0xff;
int a = buffer.get() & 0xff;
colors[i] = (a << 24) | (r << 16) | (g << 8) | b;
}
return Bitmap.createBitmap(colors, width, height, Bitmap.Config.ARGB_8888);
}
private void sendTouch(MotionEvent event) {
if (webSocket == null || desktop.getWidth() <= 0 || desktop.getHeight() <= 0) {
return;
}
String action;
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
action = "down";
break;
case MotionEvent.ACTION_UP:
action = "up";
break;
case MotionEvent.ACTION_MOVE:
action = "move";
break;
default:
return;
}
double x = Math.max(0, Math.min(1, event.getX() / Math.max(1f, desktop.getWidth())));
double y = Math.max(0, Math.min(1, event.getY() / Math.max(1f, desktop.getHeight())));
try {
JSONObject payload = new JSONObject();
payload.put("correlation_id", UUID.randomUUID().toString());
payload.put("client_captured_at", java.time.Instant.now().toString());
payload.put("kind", "mouse");
payload.put("action", action);
payload.put("button", "left");
payload.put("normalized_x", x);
payload.put("normalized_y", y);
payload.put("surface_width", desktopWidth);
payload.put("surface_height", desktopHeight);
JSONObject envelope = new JSONObject();
envelope.put("type", "input");
envelope.put("payload", payload);
webSocket.send(envelope.toString().getBytes(StandardCharsets.UTF_8).length > 0 ? envelope.toString() : "{}");
} catch (Exception ignored) {
}
}
}
@@ -0,0 +1,90 @@
package su.cin.rapvpn;
import android.content.Context;
import android.content.SharedPreferences;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.util.Base64;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.util.Arrays;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
final class SecureTokenStore {
private static final String PREFS = "rap-vpn-secure";
private static final String KEY_ALIAS = "rap-vpn-refresh-token";
private static final String ANDROID_KEYSTORE = "AndroidKeyStore";
private static final int IV_LENGTH = 12;
private static final int TAG_LENGTH_BITS = 128;
private final SharedPreferences prefs;
SecureTokenStore(Context context) {
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
void put(String name, String value) throws Exception {
if (value == null || value.isEmpty()) {
remove(name);
return;
}
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key());
byte[] ciphertext = cipher.doFinal(value.getBytes(StandardCharsets.UTF_8));
byte[] iv = cipher.getIV();
if (iv == null || iv.length == 0) {
throw new IllegalStateException("Android Keystore did not provide encryption IV");
}
byte[] payload = new byte[iv.length + ciphertext.length];
System.arraycopy(iv, 0, payload, 0, iv.length);
System.arraycopy(ciphertext, 0, payload, iv.length, ciphertext.length);
prefs.edit().putString(name, Base64.encodeToString(payload, Base64.NO_WRAP)).apply();
}
String get(String name) {
String encoded = prefs.getString(name, "");
if (encoded.isEmpty()) {
return "";
}
try {
byte[] payload = Base64.decode(encoded, Base64.NO_WRAP);
if (payload.length <= IV_LENGTH) {
return "";
}
byte[] iv = Arrays.copyOfRange(payload, 0, IV_LENGTH);
byte[] ciphertext = Arrays.copyOfRange(payload, IV_LENGTH, payload.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(TAG_LENGTH_BITS, iv));
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
} catch (Exception ignored) {
return "";
}
}
void remove(String name) {
prefs.edit().remove(name).apply();
}
private SecretKey key() throws Exception {
KeyStore keyStore = KeyStore.getInstance(ANDROID_KEYSTORE);
keyStore.load(null);
KeyStore.Entry entry = keyStore.getEntry(KEY_ALIAS, null);
if (entry instanceof KeyStore.SecretKeyEntry) {
return ((KeyStore.SecretKeyEntry) entry).getSecretKey();
}
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE);
generator.init(new KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build());
return generator.generateKey();
}
}
@@ -0,0 +1,233 @@
package su.cin.rapvpn;
import android.app.Activity;
import android.content.SharedPreferences;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.Gravity;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.LinearLayout;
import android.widget.TextView;
import java.net.HttpURLConnection;
import java.net.URL;
public class TestTrafficActivity extends Activity {
static final String PREFS = "rap-vpn-browser-test";
static final String EXTRA_URL = "url";
private TextView status;
private WebView webView;
private String target;
private int assetErrorCount;
private int mainErrorCount;
private int httpErrorCount;
private final Handler handler = new Handler(Looper.getMainLooper());
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
LinearLayout layout = new LinearLayout(this);
layout.setOrientation(LinearLayout.VERTICAL);
layout.setBackgroundColor(Color.WHITE);
status = new TextView(this);
status.setTextColor(Color.rgb(20, 30, 40));
status.setTextSize(14);
status.setGravity(Gravity.START);
status.setPadding(18, 14, 18, 14);
layout.addView(status, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT));
webView = new WebView(this);
layout.addView(webView, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0,
1f));
setContentView(layout);
String url = getIntent().getStringExtra(EXTRA_URL);
if (url == null || url.isEmpty()) {
url = "http://192.168.200.61:18080/";
}
target = url;
assetErrorCount = 0;
mainErrorCount = 0;
httpErrorCount = 0;
configureWebView();
saveStatus("starting", "open " + target, 0, target, "");
status.setText("Web test starting: " + target);
webView.loadUrl(target);
new Thread(() -> runRequest(target), "rap-test-traffic-http").start();
}
@Override
protected void onDestroy() {
try {
saveStatus("destroyed", "activity destroyed", webView == null ? 0 : webView.getProgress(), target, "");
if (webView != null) {
webView.stopLoading();
webView.destroy();
}
} catch (Exception ignored) {
}
super.onDestroy();
}
private void configureWebView() {
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setDatabaseEnabled(true);
settings.setLoadsImagesAutomatically(true);
settings.setBlockNetworkLoads(false);
settings.setCacheMode(WebSettings.LOAD_NO_CACHE);
if (android.os.Build.VERSION.SDK_INT >= 21) {
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE);
}
webView.setWebChromeClient(new WebChromeClient() {
@Override
public void onProgressChanged(WebView view, int newProgress) {
String url = view == null ? target : view.getUrl();
String title = view == null ? "" : view.getTitle();
String message = "progress=" + newProgress + " title=" + safe(title);
status.setText(message + "\n" + safe(url));
saveStatus(newProgress >= 100 ? "progress_complete" : "loading", message, newProgress, url, "");
if (newProgress >= 100) {
scheduleDomProbe(1200);
scheduleDomProbe(5000);
scheduleDomProbe(12000);
}
}
});
webView.setWebViewClient(new WebViewClient() {
@Override
public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) {
assetErrorCount = 0;
mainErrorCount = 0;
httpErrorCount = 0;
status.setText("started\n" + safe(url));
saveStatus("started", "page started", 0, url, "");
}
@Override
public void onPageFinished(WebView view, String url) {
int progress = Math.max(100, view == null ? 100 : view.getProgress());
String title = view == null ? "" : view.getTitle();
status.setText("finished progress=" + progress + "\n" + safe(title) + "\n" + safe(url));
saveStatus("finished", "page finished title=" + safe(title), progress, url, "");
scheduleDomProbe(1000);
scheduleDomProbe(5000);
scheduleDomProbe(12000);
}
@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
String url = request == null || request.getUrl() == null ? "" : request.getUrl().toString();
String description = error == null ? "unknown" : String.valueOf(error.getDescription());
boolean mainFrame = request != null && request.isForMainFrame();
if (mainFrame) {
mainErrorCount++;
} else {
assetErrorCount++;
}
status.setText("error main=" + mainFrame + "\n" + description + "\n" + url);
saveStatus(mainFrame ? "main_error" : "asset_error", description, view == null ? 0 : view.getProgress(), url, mainFrame ? "MAIN" : "ASSET");
}
@Override
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
String url = request == null || request.getUrl() == null ? "" : request.getUrl().toString();
int code = errorResponse == null ? 0 : errorResponse.getStatusCode();
boolean mainFrame = request != null && request.isForMainFrame();
httpErrorCount++;
saveStatus(mainFrame ? "main_http_error" : "asset_http_error", "HTTP " + code, view == null ? 0 : view.getProgress(), url, mainFrame ? "HTTP_MAIN" : "HTTP_ASSET");
}
});
}
private void runRequest(String target) {
String result;
try {
HttpURLConnection connection = (HttpURLConnection) new URL(target).openConnection();
connection.setConnectTimeout(30000);
connection.setReadTimeout(30000);
connection.setInstanceFollowRedirects(false);
result = "HTTP " + connection.getResponseCode();
connection.disconnect();
} catch (Exception e) {
result = e.getClass().getSimpleName() + ": " + e.getMessage();
}
String finalResult = result;
saveHttpProbe(finalResult);
runOnUiThread(() -> status.setText(status.getText() + "\nhttp_probe=" + finalResult));
}
private void saveStatus(String state, String message, int progress, String url, String errorType) {
getSharedPreferences(PREFS, MODE_PRIVATE).edit()
.putString("state", safe(state))
.putString("message", safe(message))
.putInt("progress", progress)
.putString("url", safe(url))
.putString("target_url", safe(target))
.putString("error_type", safe(errorType))
.putInt("asset_error_count", assetErrorCount)
.putInt("main_error_count", mainErrorCount)
.putInt("http_error_count", httpErrorCount)
.putLong("updated_at", System.currentTimeMillis())
.apply();
}
private void saveHttpProbe(String result) {
getSharedPreferences(PREFS, MODE_PRIVATE).edit()
.putString("http_probe", safe(result))
.putLong("http_probe_at", System.currentTimeMillis())
.apply();
}
private void scheduleDomProbe(long delayMs) {
handler.postDelayed(this::runDomProbe, Math.max(0, delayMs));
}
private void runDomProbe() {
if (webView == null) {
return;
}
String script = "(function(){"
+ "function txt(e){return ((e.innerText||e.textContent||e.value||e.getAttribute('aria-label')||'')+'').replace(/\\s+/g,' ').trim();}"
+ "function vis(e){var r=e.getBoundingClientRect();var s=getComputedStyle(e);return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none';}"
+ "var nodes=Array.prototype.slice.call(document.querySelectorAll('button,[role=button],input[type=button],input[type=submit],a'));"
+ "var buttons=nodes.map(function(e){return {text:txt(e).slice(0,48),disabled:!!e.disabled||e.getAttribute('aria-disabled')==='true'||e.classList.contains('disabled'),visible:vis(e),tag:e.tagName,cls:(e.className||'').toString().slice(0,64)};}).filter(function(x){return x.text||/button/i.test(x.tag);}).slice(0,40);"
+ "var start=buttons.filter(function(x){return /старт|start/i.test(x.text)||/start/i.test(x.cls);});"
+ "var qms=(document.documentElement.innerHTML.match(/qms\\.ru/g)||[]).length;"
+ "var out={readyState:document.readyState,title:document.title,scripts:document.scripts.length,buttons:buttons.length,start:start,qms:qms,url:location.href};"
+ "return JSON.stringify(out);"
+ "})()";
try {
webView.evaluateJavascript(script, value -> {
String probe = safe(value);
saveDomProbe(probe);
status.setText(status.getText() + "\ndom_probe=" + probe);
});
} catch (Exception e) {
saveDomProbe(e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
private void saveDomProbe(String result) {
getSharedPreferences(PREFS, MODE_PRIVATE).edit()
.putString("dom_probe", safe(result))
.putLong("dom_probe_at", System.currentTimeMillis())
.apply();
}
private String safe(String value) {
return value == null ? "" : value;
}
}
@@ -0,0 +1,70 @@
package su.cin.rapvpn;
import android.app.Activity;
import android.content.Intent;
import android.net.VpnService;
import android.os.Bundle;
import android.util.Base64;
import android.widget.TextView;
import java.nio.charset.StandardCharsets;
public class TestVpnActivity extends Activity {
public static final String EXTRA_PROFILE_JSON = "profile_json";
public static final String EXTRA_PROFILE_BASE64 = "profile_base64";
public static final String EXTRA_BACKEND_URL = "backend_url";
public static final String EXTRA_CLUSTER_ID = "cluster_id";
public static final String EXTRA_VPN_CONNECTION_ID = "vpn_connection_id";
private static final int VPN_PREPARE_REQUEST = 77;
private Intent serviceIntent;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
TextView text = new TextView(this);
text.setText("RAP VPN test launcher");
setContentView(text);
serviceIntent = buildServiceIntent(getIntent());
Intent prepare = VpnService.prepare(this);
if (prepare != null) {
startActivityForResult(prepare, VPN_PREPARE_REQUEST);
return;
}
startVpn();
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == VPN_PREPARE_REQUEST && resultCode == RESULT_OK) {
startVpn();
}
}
private Intent buildServiceIntent(Intent source) {
Intent intent = new Intent(this, RapVpnService.class);
intent.putExtra(RapVpnService.EXTRA_PROFILE_JSON, profileJson(source));
intent.putExtra(RapVpnService.EXTRA_BACKEND_URL, source.getStringExtra(EXTRA_BACKEND_URL));
intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, source.getStringExtra(EXTRA_CLUSTER_ID));
intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, source.getStringExtra(EXTRA_VPN_CONNECTION_ID));
return intent;
}
private String profileJson(Intent source) {
String direct = source.getStringExtra(EXTRA_PROFILE_JSON);
if (direct != null && !direct.isEmpty()) {
return direct;
}
String encoded = source.getStringExtra(EXTRA_PROFILE_BASE64);
if (encoded == null || encoded.isEmpty()) {
return "";
}
byte[] raw = Base64.decode(encoded, Base64.DEFAULT);
return new String(raw, StandardCharsets.UTF_8);
}
private void startVpn() {
startForegroundService(serviceIntent);
finish();
}
}
@@ -0,0 +1,288 @@
package su.cin.rapvpn;
import android.net.VpnService;
import android.util.Log;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import okhttp3.ConnectionPool;
import okhttp3.Dispatcher;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
import okio.ByteString;
final class VpnPacketWebSocketRelay {
private static final String TAG = "RapVpnWebSocketRelay";
private static final int MAX_PACKET_BATCH_PACKETS = 512;
private static final int MAX_PACKET_BATCH_BYTES = 1024 * 1024;
private static final int MAX_SINGLE_PACKET_BYTES = 65535;
private final String baseUrl;
private final VpnService vpnService;
private final OkHttpClient httpClient;
private final FabricServiceChannel fabricServiceChannel;
private final BlockingQueue<List<byte[]>> incoming = new ArrayBlockingQueue<>(2048);
private final Object lock = new Object();
private WebSocket webSocket;
private String connectedClusterId = "";
private String connectedVpnConnectionId = "";
private volatile boolean open;
private volatile boolean connecting;
private volatile long reconnectAfterMs;
private volatile String lastError = "";
VpnPacketWebSocketRelay(String baseUrl, VpnService vpnService) {
this(baseUrl, vpnService, new FabricServiceChannel());
}
VpnPacketWebSocketRelay(String baseUrl, VpnService vpnService, FabricServiceChannel fabricServiceChannel) {
this.baseUrl = trimRight(baseUrl);
this.vpnService = vpnService;
this.fabricServiceChannel = fabricServiceChannel == null ? new FabricServiceChannel() : fabricServiceChannel;
OkHttpClient.Builder builder = new OkHttpClient.Builder();
if (vpnService != null) {
builder.socketFactory(new RapApiClient.ProtectedSocketFactory(vpnService));
}
builder.dns(new RapApiClient.BackendPinnedDns(baseUrl));
builder.connectTimeout(5, TimeUnit.SECONDS);
builder.writeTimeout(10, TimeUnit.SECONDS);
builder.readTimeout(0, TimeUnit.SECONDS);
builder.retryOnConnectionFailure(true);
Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(16);
dispatcher.setMaxRequestsPerHost(8);
builder.dispatcher(dispatcher);
builder.connectionPool(new ConnectionPool(8, 5, TimeUnit.MINUTES));
this.httpClient = builder.build();
}
String baseUrl() {
return baseUrl;
}
boolean isOpen() {
return open;
}
String lastError() {
return lastError == null ? "" : lastError;
}
void connect(String clusterId, String vpnConnectionId) {
if (clusterId == null || clusterId.isEmpty() || vpnConnectionId == null || vpnConnectionId.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
synchronized (lock) {
if (open && clusterId.equals(connectedClusterId) && vpnConnectionId.equals(connectedVpnConnectionId)) {
return;
}
if (connecting && clusterId.equals(connectedClusterId) && vpnConnectionId.equals(connectedVpnConnectionId)) {
return;
}
if (now < reconnectAfterMs) {
return;
}
closeLocked();
String wsUrl = webSocketUrl(clusterId, vpnConnectionId);
if (wsUrl.isEmpty()) {
lastError = "invalid websocket url";
reconnectAfterMs = now + 5000;
return;
}
connectedClusterId = clusterId;
connectedVpnConnectionId = vpnConnectionId;
connecting = true;
Request.Builder requestBuilder = new Request.Builder().url(wsUrl);
this.fabricServiceChannel.applyHeaders(requestBuilder);
Request request = requestBuilder.build();
webSocket = httpClient.newWebSocket(request, new Listener());
}
}
boolean sendClientPacketBatch(String clusterId, String vpnConnectionId, List<byte[]> packets) {
packets = cleanPacketBatch(packets);
if (packets.isEmpty()) {
return true;
}
connect(clusterId, vpnConnectionId);
WebSocket socket = webSocket;
if (socket == null || !open) {
return false;
}
byte[] payload = encodePacketBatch(packets);
if (payload.length == 0) {
return true;
}
boolean queued = socket.send(ByteString.of(payload));
if (!queued) {
lastError = "websocket send queue rejected batch";
}
return queued;
}
List<byte[]> receiveClientPacketBatch(String clusterId, String vpnConnectionId, int timeoutMs) throws InterruptedException {
connect(clusterId, vpnConnectionId);
int waitMs = Math.max(1, timeoutMs);
List<byte[]> packets = incoming.poll(waitMs, TimeUnit.MILLISECONDS);
return packets == null ? new ArrayList<>() : packets;
}
void close() {
synchronized (lock) {
closeLocked();
}
}
private void closeLocked() {
open = false;
connecting = false;
incoming.clear();
if (webSocket != null) {
try {
webSocket.close(1000, "relay switch");
} catch (Exception ignored) {
}
}
webSocket = null;
}
private String webSocketUrl(String clusterId, String vpnConnectionId) {
try {
URI uri = URI.create(baseUrl);
String scheme = "https".equalsIgnoreCase(uri.getScheme()) ? "wss" : "ws";
String path = uri.getRawPath() == null || uri.getRawPath().isEmpty() ? "" : trimRight(uri.getRawPath());
String fabricPath = fabricServiceChannel.packetPathForBase(baseUrl, clusterId, vpnConnectionId, true);
if (!fabricPath.isEmpty()) {
path += fabricPath;
} else {
path += "/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets/ws";
}
URI ws = new URI(scheme, uri.getRawUserInfo(), uri.getHost(), uri.getPort(), path, null, null);
return ws.toString();
} catch (Exception e) {
lastError = e.getClass().getSimpleName() + ": " + e.getMessage();
return "";
}
}
private final class Listener extends WebSocketListener {
@Override
public void onOpen(WebSocket webSocket, Response response) {
open = true;
connecting = false;
reconnectAfterMs = 0;
lastError = "";
Log.i(TAG, "vpn packet websocket opened " + baseUrl);
}
@Override
public void onMessage(WebSocket webSocket, ByteString bytes) {
List<byte[]> packets = decodePacketBatch(bytes.toByteArray());
if (packets.isEmpty()) {
return;
}
if (!incoming.offer(packets)) {
incoming.poll();
incoming.offer(packets);
}
}
@Override
public void onClosed(WebSocket webSocket, int code, String reason) {
open = false;
connecting = false;
reconnectAfterMs = System.currentTimeMillis() + 1000;
lastError = "closed " + code + " " + reason;
}
@Override
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
open = false;
connecting = false;
reconnectAfterMs = System.currentTimeMillis() + 3000;
lastError = t == null ? "websocket failure" : t.getClass().getSimpleName() + ": " + t.getMessage();
Log.w(TAG, "vpn packet websocket failed " + baseUrl + ": " + lastError);
}
}
private static List<byte[]> cleanPacketBatch(List<byte[]> packets) {
List<byte[]> cleaned = new ArrayList<>();
int bytes = 0;
if (packets == null) {
return cleaned;
}
for (byte[] packet : packets) {
if (packet == null || packet.length <= 0 || packet.length > MAX_SINGLE_PACKET_BYTES) {
continue;
}
int projected = bytes + 4 + packet.length;
if (cleaned.size() >= MAX_PACKET_BATCH_PACKETS || projected > MAX_PACKET_BATCH_BYTES) {
break;
}
cleaned.add(packet);
bytes = projected;
}
return cleaned;
}
private static byte[] encodePacketBatch(List<byte[]> packets) {
packets = cleanPacketBatch(packets);
int total = 0;
for (byte[] packet : packets) {
total += 4 + packet.length;
}
byte[] out = new byte[total];
int offset = 0;
for (byte[] packet : packets) {
int length = packet.length;
out[offset] = (byte) ((length >> 24) & 0xff);
out[offset + 1] = (byte) ((length >> 16) & 0xff);
out[offset + 2] = (byte) ((length >> 8) & 0xff);
out[offset + 3] = (byte) (length & 0xff);
offset += 4;
System.arraycopy(packet, 0, out, offset, length);
offset += length;
}
return out;
}
private static List<byte[]> decodePacketBatch(byte[] payload) {
List<byte[]> packets = new ArrayList<>();
int offset = 0;
while (payload != null && offset + 4 <= payload.length && packets.size() < MAX_PACKET_BATCH_PACKETS) {
int length = ((payload[offset] & 0xff) << 24)
| ((payload[offset + 1] & 0xff) << 16)
| ((payload[offset + 2] & 0xff) << 8)
| (payload[offset + 3] & 0xff);
offset += 4;
if (length <= 0 || length > MAX_SINGLE_PACKET_BYTES || offset + length > payload.length) {
break;
}
byte[] packet = new byte[length];
System.arraycopy(payload, offset, packet, 0, length);
packets.add(packet);
offset += length;
}
return packets;
}
private static String trimRight(String value) {
if (value == null) {
return "";
}
while (value.endsWith("/")) {
value = value.substring(0, value.length() - 1);
}
return value;
}
}
@@ -0,0 +1,7 @@
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:colorAccent">#2f6f50</item>
</style>
</resources>
+3
View File
@@ -0,0 +1,3 @@
plugins {
id "com.android.application" version "9.2.0" apply false
}
+1
View File
@@ -0,0 +1 @@
sdk.dir=C:/Android/sdk
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "RapAndroidVpn"
include ":app"
+3
View File
@@ -10,6 +10,9 @@ real RDP session window with direct worker data-plane support.
- application services for auth, organizations, resources, sessions, and gateway attach flows
- secure local token storage via DPAPI for MVP
- organization selection persisted locally
- remote-desktop-first shell: the selected server/session surface is primary,
while organization, server catalog, and active sessions stay in compact
controls or collapsible side panels
- resource list, active session list, and session window
- direct worker WSS data-plane integration with backend gateway fallback
- binary render receive path for direct worker WSS
@@ -21,10 +21,10 @@
</Grid.RowDefinitions>
<Border Grid.Row="0"
Margin="0,0,0,16"
Padding="18"
Background="#FF0E3B43"
CornerRadius="14">
Margin="0,0,0,12"
Padding="14"
Background="#FF10262F"
CornerRadius="8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
@@ -32,7 +32,7 @@
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Foreground="White"
FontSize="28"
FontSize="20"
FontWeight="SemiBold"
Text="{x:Static appres:Strings.MainHeaderTitle}" />
<TextBlock Margin="0,4,0,0"
@@ -42,14 +42,15 @@
</StackPanel>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
HorizontalAlignment="Right"
VerticalAlignment="Center">
<TextBlock Foreground="White"
FontWeight="SemiBold"
Margin="0,0,12,0"
AutomationProperties.AutomationId="CurrentUserText"
Text="{Binding CurrentUserEmail}" />
<Button Margin="0,10,0,0"
Padding="14,6"
<Button Padding="12,5"
AutomationProperties.AutomationId="LogoutButton"
AutomationProperties.Name="{x:Static appres:Strings.MainLogout}"
Command="{Binding LogoutCommand}"
@@ -66,63 +67,119 @@
<Grid Visibility="{Binding IsAuthenticated, Converter={StaticResource BooleanToVisibilityConverter}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<views:OrganizationSwitchView Grid.Row="0"
Margin="0,0,0,16" />
<Border Grid.Row="1"
Margin="0,0,0,16"
Padding="16"
<Border Grid.Row="0"
Margin="0,0,0,12"
Padding="12"
Background="White"
BorderBrush="#FFD9DDE5"
BorderThickness="1"
CornerRadius="12">
<StackPanel Orientation="Horizontal">
<Button Margin="0,0,12,0"
Padding="14,8"
AutomationProperties.AutomationId="StartSessionButton"
AutomationProperties.Name="{x:Static appres:Strings.MainStartSession}"
Command="{Binding StartSessionCommand}"
CommandParameter="{Binding SelectedResource}"
Content="{x:Static appres:Strings.MainStartSession}" />
<Button Margin="0,0,12,0"
Padding="14,8"
AutomationProperties.AutomationId="AttachSessionButton"
AutomationProperties.Name="{x:Static appres:Strings.MainAttach}"
Command="{Binding AttachSessionCommand}"
CommandParameter="{Binding SelectedSession}"
Content="{x:Static appres:Strings.MainAttach}" />
<Button Margin="0,0,12,0"
Padding="14,8"
AutomationProperties.AutomationId="TakeOverSessionButton"
AutomationProperties.Name="{x:Static appres:Strings.MainTakeOver}"
Command="{Binding TakeOverSessionCommand}"
CommandParameter="{Binding SelectedSession}"
Content="{x:Static appres:Strings.MainTakeOver}" />
<Button Padding="14,8"
AutomationProperties.AutomationId="TerminateSessionButton"
AutomationProperties.Name="{x:Static appres:Strings.MainTerminate}"
Command="{Binding TerminateSessionCommand}"
CommandParameter="{Binding SelectedSession}"
Content="{x:Static appres:Strings.MainTerminate}" />
</StackPanel>
CornerRadius="8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ComboBox Width="260"
AutomationProperties.AutomationId="OrganizationComboBox"
DisplayMemberPath="Name"
ItemsSource="{Binding Organizations}"
SelectedItem="{Binding SelectedOrganization}" />
<Button Grid.Column="1"
HorizontalAlignment="Left"
Margin="10,0,0,0"
Padding="12,5"
AutomationProperties.AutomationId="SetActiveOrganizationButton"
Command="{Binding SwitchOrganizationCommand}"
Content="{x:Static appres:Strings.OrganizationSetActive}" />
<StackPanel Grid.Column="2"
Orientation="Horizontal">
<ToggleButton x:Name="ServerMenuButton"
Margin="0,0,8,0"
Padding="14,7"
AutomationProperties.AutomationId="ServerPickerButton"
Content="Серверы" />
<Button Margin="0,0,8,0"
Padding="14,7"
AutomationProperties.AutomationId="StartSessionButton"
Command="{Binding StartSessionCommand}"
CommandParameter="{Binding SelectedResource}"
Content="{x:Static appres:Strings.MainStartSession}" />
<Button Margin="0,0,8,0"
Padding="14,7"
AutomationProperties.AutomationId="AttachSessionButton"
Command="{Binding AttachSessionCommand}"
CommandParameter="{Binding SelectedSession}"
Content="{x:Static appres:Strings.MainAttach}" />
<Button Padding="14,7"
AutomationProperties.AutomationId="TerminateSessionButton"
Command="{Binding TerminateSessionCommand}"
CommandParameter="{Binding SelectedSession}"
Content="{x:Static appres:Strings.MainTerminate}" />
</StackPanel>
</Grid>
</Border>
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<views:ResourceListView Grid.Column="0"
Margin="0,0,10,0" />
<views:ActiveSessionsView Grid.Column="1"
Margin="10,0,0,0" />
<Grid Grid.Row="1">
<Border
Background="#FF111C24"
BorderBrush="#FF223747"
BorderThickness="1"
CornerRadius="8">
<Grid>
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Width="520">
<TextBlock Foreground="White"
FontSize="24"
FontWeight="SemiBold"
Text="{Binding SelectedResource.Name, TargetNullValue='Выберите сервер'}" />
<TextBlock Margin="0,8,0,18"
Foreground="#FFC0D0DA"
Text="{Binding SelectedResource.Address, TargetNullValue='Каталог серверов и активные сессии открыты справа.'}" />
<Button Width="180"
Padding="16,9"
HorizontalAlignment="Center"
AutomationProperties.AutomationId="StartSessionFromSurfaceButton"
Command="{Binding StartSessionCommand}"
CommandParameter="{Binding SelectedResource}"
Content="{x:Static appres:Strings.MainStartSession}" />
</StackPanel>
</Grid>
</Border>
</Grid>
<Popup IsOpen="{Binding IsChecked, ElementName=ServerMenuButton}"
PlacementTarget="{Binding ElementName=ServerMenuButton}"
Placement="Bottom"
StaysOpen="False"
AllowsTransparency="True"
PopupAnimation="Fade">
<Border Width="760"
MaxHeight="680"
Padding="14"
Background="#FFF7FAFC"
BorderBrush="#FFB9C5CC"
BorderThickness="1"
CornerRadius="8">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock FontSize="18"
FontWeight="SemiBold"
Text="Выбор сервера" />
<views:ResourceListView Margin="0,10,0,12" />
<TextBlock FontSize="18"
FontWeight="SemiBold"
Text="Открытые подключения" />
<views:ActiveSessionsView Margin="0,10,0,0" />
</StackPanel>
</ScrollViewer>
</Border>
</Popup>
</Grid>
</Grid>
@@ -13,149 +13,116 @@
ShowInTaskbar="True"
WindowState="Maximized"
Title="{Binding Title}">
<Grid Margin="18">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Grid.Row="0"
Padding="16"
Background="#FF143C53"
CornerRadius="12">
<Grid Background="#FF05090C">
<Border x:Name="SessionSurface"
Padding="0"
Background="#FF05090C"
Focusable="True"
KeyboardNavigation.TabNavigation="None"
KeyboardNavigation.DirectionalNavigation="None"
AutomationProperties.AutomationId="SessionWindowSurface"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowSurfaceTitle}"
GotKeyboardFocus="OnSessionSurfaceGotKeyboardFocus"
LostKeyboardFocus="OnSessionSurfaceLostKeyboardFocus"
PreviewKeyDown="OnSessionSurfacePreviewKeyDown"
PreviewKeyUp="OnSessionSurfacePreviewKeyUp"
PreviewMouseMove="OnSessionSurfacePreviewMouseMove"
PreviewMouseDown="OnSessionSurfacePreviewMouseDown"
PreviewMouseUp="OnSessionSurfacePreviewMouseUp"
PreviewMouseWheel="OnSessionSurfacePreviewMouseWheel">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock FontSize="24"
<Image x:Name="SessionFrameImage"
Stretch="Uniform"
IsHitTestVisible="False"
RenderOptions.BitmapScalingMode="LowQuality"
SnapsToDevicePixels="True" />
<Border HorizontalAlignment="Left"
VerticalAlignment="Bottom"
Margin="12"
Padding="8,4"
Background="#AA10212C"
CornerRadius="6">
<TextBlock Foreground="#FF9FD4F1"
AutomationProperties.AutomationId="SessionWindowInputStatusText"
Text="{Binding InputStatusDisplay}" />
</Border>
<StackPanel x:Name="SessionSurfaceOverlay"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsHitTestVisible="False"
Width="320">
<TextBlock HorizontalAlignment="Center"
FontSize="20"
FontWeight="SemiBold"
Foreground="White"
AutomationProperties.AutomationId="SessionWindowSessionIdText"
Text="{Binding SessionId}" />
<TextBlock Margin="0,6,0,0"
Text="{x:Static appres:Strings.SessionWindowSurfaceTitle}" />
<TextBlock Margin="0,10,0,0"
TextAlignment="Center"
Foreground="#FFD1E1ED"
AutomationProperties.AutomationId="SessionWindowStateText"
Text="{Binding SessionStateDisplay}" />
<TextBlock Foreground="#FFD1E1ED"
AutomationProperties.AutomationId="SessionWindowConnectionStatusText"
Text="{Binding ConnectionStatusDisplay}" />
<TextBlock Margin="0,6,0,0"
Foreground="White"
FontWeight="SemiBold"
AutomationProperties.AutomationId="SessionWindowShellStateText"
Text="{Binding ShellStateDisplay}" />
</StackPanel>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
VerticalAlignment="Top">
<Button Margin="0,0,10,0"
Padding="12,6"
Click="OnSessionActionInvoked"
AutomationProperties.AutomationId="SessionWindowReconnectButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowReconnect}"
Command="{Binding ReconnectCommand}"
Content="{x:Static appres:Strings.SessionWindowReconnect}" />
<Button Margin="0,0,10,0"
Padding="12,6"
Click="OnSessionActionInvoked"
AutomationProperties.AutomationId="SessionWindowDetachButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowDetach}"
Command="{Binding DetachCommand}"
Content="{x:Static appres:Strings.SessionWindowDetach}" />
<Button Margin="0,0,10,0"
Padding="12,6"
Click="OnSessionActionInvoked"
AutomationProperties.AutomationId="SessionWindowTakeOverButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowTakeOver}"
Command="{Binding TakeOverThisDeviceCommand}"
Content="{x:Static appres:Strings.SessionWindowTakeOver}" />
<Button Padding="12,6"
Click="OnSessionActionInvoked"
AutomationProperties.AutomationId="SessionWindowTerminateButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowTerminate}"
Command="{Binding TerminateCommand}"
Content="{x:Static appres:Strings.SessionWindowTerminate}" />
Text="{x:Static appres:Strings.SessionWindowSurfaceSubtitle}" />
</StackPanel>
</Grid>
</Border>
<Border Grid.Row="1"
Padding="12"
Background="White"
BorderBrush="#FFD9DDE5"
BorderThickness="1"
CornerRadius="12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="5*" />
<ColumnDefinition Width="18" />
<ColumnDefinition Width="2*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0"
Background="#FF12202A"
BorderBrush="#FF244456"
BorderThickness="1"
CornerRadius="10">
<Border x:Name="SessionSurface"
Padding="0"
Background="Transparent"
Focusable="True"
KeyboardNavigation.TabNavigation="None"
KeyboardNavigation.DirectionalNavigation="None"
AutomationProperties.AutomationId="SessionWindowSurface"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowSurfaceTitle}"
GotKeyboardFocus="OnSessionSurfaceGotKeyboardFocus"
LostKeyboardFocus="OnSessionSurfaceLostKeyboardFocus"
PreviewKeyDown="OnSessionSurfacePreviewKeyDown"
PreviewKeyUp="OnSessionSurfacePreviewKeyUp"
PreviewMouseMove="OnSessionSurfacePreviewMouseMove"
PreviewMouseDown="OnSessionSurfacePreviewMouseDown"
PreviewMouseUp="OnSessionSurfacePreviewMouseUp"
PreviewMouseWheel="OnSessionSurfacePreviewMouseWheel">
<Grid>
<Image x:Name="SessionFrameImage"
Stretch="Uniform"
IsHitTestVisible="False"
RenderOptions.BitmapScalingMode="LowQuality"
SnapsToDevicePixels="True" />
<Border HorizontalAlignment="Left"
VerticalAlignment="Bottom"
Margin="12"
Padding="8,4"
Background="#AA10212C"
CornerRadius="8">
<TextBlock Foreground="#FF9FD4F1"
AutomationProperties.AutomationId="SessionWindowInputStatusText"
Text="{Binding InputStatusDisplay}" />
</Border>
<StackPanel x:Name="SessionSurfaceOverlay"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsHitTestVisible="False"
Width="320">
<TextBlock HorizontalAlignment="Center"
FontSize="20"
FontWeight="SemiBold"
Foreground="White"
Text="{x:Static appres:Strings.SessionWindowSurfaceTitle}" />
<TextBlock Margin="0,10,0,0"
TextAlignment="Center"
Foreground="#FFD1E1ED"
Text="{x:Static appres:Strings.SessionWindowSurfaceSubtitle}" />
</StackPanel>
</Grid>
</Border>
</Border>
<ScrollViewer Grid.Column="2"
Focusable="False"
KeyboardNavigation.TabNavigation="None"
KeyboardNavigation.DirectionalNavigation="None"
VerticalScrollBarVisibility="Auto">
<StackPanel>
<Expander HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="12"
Width="360"
IsExpanded="False"
Background="#EE10262F"
Foreground="White">
<Expander.Header>
<TextBlock FontWeight="SemiBold"
Text="{Binding SessionStateDisplay}" />
</Expander.Header>
<ScrollViewer MaxHeight="720"
Focusable="False"
KeyboardNavigation.TabNavigation="None"
KeyboardNavigation.DirectionalNavigation="None"
VerticalScrollBarVisibility="Auto">
<StackPanel Margin="10">
<TextBlock Foreground="#FFD1E1ED"
AutomationProperties.AutomationId="SessionWindowSessionIdText"
Text="{Binding SessionId}" />
<TextBlock Margin="0,4,0,0"
Foreground="#FFD1E1ED"
AutomationProperties.AutomationId="SessionWindowConnectionStatusText"
Text="{Binding ConnectionStatusDisplay}" />
<TextBlock Margin="0,4,0,12"
Foreground="White"
FontWeight="SemiBold"
AutomationProperties.AutomationId="SessionWindowShellStateText"
Text="{Binding ShellStateDisplay}" />
<WrapPanel Margin="0,0,0,12">
<Button Margin="0,0,8,8"
Padding="12,6"
Click="OnSessionActionInvoked"
AutomationProperties.AutomationId="SessionWindowReconnectButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowReconnect}"
Command="{Binding ReconnectCommand}"
Content="{x:Static appres:Strings.SessionWindowReconnect}" />
<Button Margin="0,0,8,8"
Padding="12,6"
Click="OnSessionActionInvoked"
AutomationProperties.AutomationId="SessionWindowDetachButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowDetach}"
Command="{Binding DetachCommand}"
Content="{x:Static appres:Strings.SessionWindowDetach}" />
<Button Margin="0,0,8,8"
Padding="12,6"
Click="OnSessionActionInvoked"
AutomationProperties.AutomationId="SessionWindowTakeOverButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowTakeOver}"
Command="{Binding TakeOverThisDeviceCommand}"
Content="{x:Static appres:Strings.SessionWindowTakeOver}" />
<Button Margin="0,0,8,8"
Padding="12,6"
Click="OnSessionActionInvoked"
AutomationProperties.AutomationId="SessionWindowTerminateButton"
AutomationProperties.Name="{x:Static appres:Strings.SessionWindowTerminate}"
Command="{Binding TerminateCommand}"
Content="{x:Static appres:Strings.SessionWindowTerminate}" />
</WrapPanel>
<Expander IsExpanded="False"
Focusable="False"
IsTabStop="False">
@@ -275,7 +242,7 @@
</Expander>
<Expander Margin="0,12,0,0"
IsExpanded="True"
IsExpanded="False"
Focusable="False"
IsTabStop="False">
<Expander.Header>
@@ -292,7 +259,6 @@
</Expander>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</Expander>
</Grid>
</Window>
@@ -1,8 +1,8 @@
{
"backend": {
"prefer_direct_data_plane": true,
"api_base_url": "http://192.168.200.61:8080/api/v1",
"gateway_websocket_url": "ws://192.168.200.61:8080/api/v1/gateway/ws",
"api_base_url": "http://195.123.240.88:19131/api/v1",
"gateway_websocket_url": "ws://195.123.240.88:19131/api/v1/gateway/ws",
"environment": "development",
"direct_data_plane_connect_timeout_ms": 2500,
"direct_data_plane_color_mode": "full_color",