Record project continuation changes
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
#Fri May 01 13:10:35 MSK 2026
|
||||
gradle.version=9.5.0
|
||||
Binary file not shown.
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "su.cin.rapvpn"
|
||||
compileSdk 35
|
||||
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "su.cin.rapvpn"
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 64
|
||||
versionName "0.2.64"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<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.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>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,650 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.Intent;
|
||||
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 = "http://195.123.240.88:19131/api/v1";
|
||||
private static final String DEFAULT_ENTRY_NODE_ID = "b829ffde-690b-47ab-9522-0f22ab42596d";
|
||||
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";
|
||||
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", "cfc0743d-d960-49fb-9de8-96e063d5e4aa"));
|
||||
organizationId = field("Organization ID", prefs.getString("organization_id", "125ff8b2-5ac1-4406-9bbb-ebbe18f7c7ed"));
|
||||
email = field("Email", prefs.getString("email", "m"));
|
||||
password = field("Password", "");
|
||||
password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
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);
|
||||
startService(stopIntent);
|
||||
status.setText("VPN остановлен.");
|
||||
runtimeStatus.setText(runtimeStatusText());
|
||||
});
|
||||
|
||||
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);
|
||||
profileJson = client.vpnClientProfile(clusterId.getText().toString(), activeOrganizationId, authContext.userId, DEFAULT_ENTRY_NODE_ID);
|
||||
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);
|
||||
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());
|
||||
}
|
||||
|
||||
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);
|
||||
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) + " сек назад";
|
||||
return "Диагностика: " + state
|
||||
+ "\n" + message
|
||||
+ "\nread/sent/down: " + read + "/" + sent + "/" + down
|
||||
+ "\nerrors/drops: " + errors + "/" + (droppedRead + droppedDown)
|
||||
+ "\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)
|
||||
+ "\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 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");
|
||||
}
|
||||
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;
|
||||
return "Версия: " + APP_VERSION
|
||||
+ "\nВход: usa-los-1"
|
||||
+ "\nОрганизация: HOME"
|
||||
+ "\nВыход: home-1"
|
||||
+ "\nBackend: " + backendUrl.getText()
|
||||
+ "\nDevice: " + (deviceId.isEmpty() ? "нет" : deviceId)
|
||||
+ "\nConnection: " + (connectionId.isEmpty() ? "нет" : connectionId);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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 String normalizeBackendUrl(String value) {
|
||||
String candidate = value == null ? "" : value.trim().replaceAll("/+$", "");
|
||||
if (candidate.isEmpty() || isLegacyControlPlaneUrl(candidate)) {
|
||||
return DEFAULT_BACKEND_URL;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private boolean isLegacyControlPlaneUrl(String value) {
|
||||
String lower = value.toLowerCase();
|
||||
return lower.equals("http://94.141.118.222:19191/api/v1")
|
||||
|| lower.equals("http://vpn.cin.su:19191/api/v1")
|
||||
|| lower.equals("http://192.168.200.61:18080/api/v1")
|
||||
|| lower.equals("http://docker-test.cin.su:18080/api/v1")
|
||||
|| lower.equals("http://docker-test.cin.su/api/v1")
|
||||
|| lower.equals("http://192.168.200.61/api/v1");
|
||||
}
|
||||
|
||||
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 (не сохраняется)");
|
||||
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());
|
||||
});
|
||||
form.addView(backendDraft);
|
||||
form.addView(clusterDraft);
|
||||
form.addView(organizationDraft);
|
||||
form.addView(emailDraft);
|
||||
form.addView(passwordDraft);
|
||||
form.addView(showPassword);
|
||||
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());
|
||||
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,583 @@
|
||||
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.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.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 = 128;
|
||||
private static final int MAX_PACKET_BATCH_BYTES = 128 * 1024;
|
||||
private static final int MAX_SINGLE_PACKET_BYTES = 65535;
|
||||
private static final int MAX_BATCH_HEADER_BYTES = 4;
|
||||
private static final int BATCH_RETRY_THRESHOLD = 2;
|
||||
private final String baseUrl;
|
||||
private final OkHttpClient httpClient;
|
||||
private final String networkMode;
|
||||
private volatile boolean batchModeEnabled = true;
|
||||
private volatile int batchModeFailures = 0;
|
||||
|
||||
RapApiClient(String baseUrl) {
|
||||
this(baseUrl, null);
|
||||
}
|
||||
|
||||
RapApiClient(String baseUrl, Context context) {
|
||||
this.baseUrl = trimRight(baseUrl);
|
||||
OkHttpClient.Builder builder = new OkHttpClient.Builder();
|
||||
SocketFactory socketFactory = context == null ? null : underlyingSocketFactory(context);
|
||||
if (socketFactory != null) {
|
||||
builder.socketFactory(socketFactory);
|
||||
this.networkMode = "direct_network";
|
||||
} else {
|
||||
this.networkMode = "default_network";
|
||||
}
|
||||
builder.connectTimeout(10, TimeUnit.SECONDS);
|
||||
builder.writeTimeout(45, TimeUnit.SECONDS);
|
||||
builder.readTimeout(45, TimeUnit.SECONDS);
|
||||
builder.callTimeout(50, 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 = trimRight(baseUrl);
|
||||
OkHttpClient.Builder builder = new OkHttpClient.Builder();
|
||||
if (vpnService != null) {
|
||||
SocketFactory socketFactory = underlyingSocketFactory(vpnService);
|
||||
builder.socketFactory(socketFactory != null ? socketFactory : new ProtectedSocketFactory(vpnService));
|
||||
this.networkMode = socketFactory != null ? "direct_network" : "protected_socket";
|
||||
} else {
|
||||
this.networkMode = "default_network";
|
||||
}
|
||||
builder.connectTimeout(10, TimeUnit.SECONDS);
|
||||
builder.writeTimeout(45, TimeUnit.SECONDS);
|
||||
builder.readTimeout(45, TimeUnit.SECONDS);
|
||||
builder.callTimeout(50, 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;
|
||||
}
|
||||
|
||||
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 entryNodeId) throws Exception {
|
||||
String path = "/clusters/" + clusterId + "/vpn/client-profile?organization_id=" + organizationId + "&user_id=" + userId;
|
||||
if (entryNodeId != null && !entryNodeId.trim().isEmpty()) {
|
||||
path += "&entry_node_id=" + entryNodeId.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("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets", packet, length);
|
||||
}
|
||||
|
||||
void sendClientPacketBatch(String clusterId, String vpnConnectionId, List<byte[]> packets) throws Exception {
|
||||
if (!batchModeEnabled) {
|
||||
for (byte[] packet : packets) {
|
||||
if (packet == null || packet.length == 0) {
|
||||
continue;
|
||||
}
|
||||
sendClientPacket(clusterId, vpnConnectionId, packet, packet.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (packets == null || packets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
List<List<byte[]>> chunks = chunkPacketsForBatch(packets);
|
||||
if (chunks.isEmpty()) {
|
||||
for (byte[] packet : packets) {
|
||||
if (packet == null || packet.length == 0) {
|
||||
continue;
|
||||
}
|
||||
sendClientPacket(clusterId, vpnConnectionId, packet, packet.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (List<byte[]> chunk : chunks) {
|
||||
postBytes("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets?batch=true", encodePacketBatch(chunk));
|
||||
}
|
||||
resetBatchMode();
|
||||
} catch (Exception e) {
|
||||
if (shouldDisableBatchMode(e)) {
|
||||
disableBatchMode();
|
||||
for (byte[] packet : packets) {
|
||||
if (packet == null || packet.length == 0) {
|
||||
continue;
|
||||
}
|
||||
sendClientPacket(clusterId, vpnConnectionId, packet, packet.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
byte[] receiveClientPacket(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception {
|
||||
try {
|
||||
return getBytes("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets?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 {
|
||||
if (!batchModeEnabled) {
|
||||
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
|
||||
}
|
||||
byte[] payload;
|
||||
try {
|
||||
payload = getBytes("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets?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<>();
|
||||
}
|
||||
if (shouldDisableBatchMode(e)) {
|
||||
disableBatchMode();
|
||||
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
|
||||
}
|
||||
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<>();
|
||||
}
|
||||
if (shouldDisableBatchMode(e)) {
|
||||
disableBatchMode();
|
||||
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
|
||||
}
|
||||
throw e;
|
||||
} catch (RuntimeException e) {
|
||||
if (shouldDisableBatchMode(e)) {
|
||||
disableBatchMode();
|
||||
return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs);
|
||||
}
|
||||
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 request = new Request.Builder().url(baseUrl + path).get().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 request = new Request.Builder()
|
||||
.url(baseUrl + path)
|
||||
.post(RequestBody.create(bodyBytes, OCTET_STREAM))
|
||||
.build();
|
||||
try (Response response = httpClient.newCall(request).execute()) {
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IllegalStateException("HTTP " + response.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 boolean shouldDisableBatchMode(Throwable error) {
|
||||
return error != null;
|
||||
}
|
||||
|
||||
private synchronized void disableBatchMode() {
|
||||
batchModeFailures++;
|
||||
if (batchModeFailures >= BATCH_RETRY_THRESHOLD) {
|
||||
batchModeEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void resetBatchMode() {
|
||||
batchModeFailures = 0;
|
||||
batchModeEnabled = true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private 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 {
|
||||
return protect(delegate.createSocket());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port) throws IOException {
|
||||
Socket socket = protect(delegate.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 = protect(delegate.createSocket());
|
||||
socket.bind(new InetSocketAddress(localHost, localPort));
|
||||
socket.connect(new InetSocketAddress(host, port));
|
||||
return socket;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(InetAddress host, int port) throws IOException {
|
||||
Socket socket = protect(delegate.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 = protect(delegate.createSocket());
|
||||
socket.bind(new InetSocketAddress(localAddress, localPort));
|
||||
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,708 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.net.Uri;
|
||||
import android.net.VpnService;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.InetAddress;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
|
||||
public class RapDiagnosticService extends Service {
|
||||
static final String ACTION_START = "su.cin.rapvpn.DIAGNOSTIC_START";
|
||||
static final String ACTION_STOP = "su.cin.rapvpn.DIAGNOSTIC_STOP";
|
||||
private static final String CHANNEL_ID = "rap-vpn-diagnostics";
|
||||
private static final String APP_VERSION = BuildConfig.VERSION_NAME;
|
||||
private static final String DEFAULT_BACKEND_URL = "http://195.123.240.88:19131/api/v1";
|
||||
private static final String DEFAULT_ENTRY_NODE_ID = "b829ffde-690b-47ab-9522-0f22ab42596d";
|
||||
private static final String PREFS = "rap-vpn";
|
||||
private static final String RUNTIME_PREFS = "rap-vpn-runtime";
|
||||
private static final String PREF_REFRESH_TOKEN = "refresh_token";
|
||||
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";
|
||||
private volatile boolean running;
|
||||
private Thread worker;
|
||||
private String serviceState = "";
|
||||
private String lastCommandType = "";
|
||||
private String lastCommandResult = "";
|
||||
private long lastCommandAt = 0;
|
||||
private long lastHeartbeatAt = 0;
|
||||
private long lastCommandPollAt = 0;
|
||||
private String controlNetworkMode = "";
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
|
||||
running = false;
|
||||
if (worker != null) {
|
||||
worker.interrupt();
|
||||
}
|
||||
stopForeground(true);
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
startForeground(1002, notification());
|
||||
startWorker();
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
running = false;
|
||||
if (worker != null) {
|
||||
worker.interrupt();
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
static void start(android.content.Context context) {
|
||||
Intent intent = new Intent(context, RapDiagnosticService.class);
|
||||
intent.setAction(ACTION_START);
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
context.startForegroundService(intent);
|
||||
} else {
|
||||
context.startService(intent);
|
||||
}
|
||||
}
|
||||
|
||||
private void startWorker() {
|
||||
if (worker != null && worker.isAlive()) {
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
worker = new Thread(this::runLoop, "rap-vpn-diagnostic-service");
|
||||
worker.start();
|
||||
}
|
||||
|
||||
private void runLoop() {
|
||||
while (running) {
|
||||
try {
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
String backendUrl = normalizeBackendUrl(prefs.getString("backend_url", ""));
|
||||
if (!backendUrl.equals(prefs.getString("backend_url", ""))) {
|
||||
prefs.edit().putString("backend_url", backendUrl).apply();
|
||||
}
|
||||
String clusterId = prefs.getString("cluster_id", "");
|
||||
String deviceId = prefs.getString(PREF_DEVICE_ID, "");
|
||||
if (backendUrl.isEmpty() || clusterId.isEmpty() || deviceId.isEmpty()) {
|
||||
Thread.sleep(3000);
|
||||
continue;
|
||||
}
|
||||
RapApiClient client = new RapApiClient(backendUrl, this);
|
||||
controlNetworkMode = client.networkMode();
|
||||
lastHeartbeatAt = System.currentTimeMillis();
|
||||
serviceState = "online " + new SimpleDateFormat("HH:mm:ss").format(new Date());
|
||||
client.reportVPNDiagnosticStatus(clusterId, deviceId, statusPayload("heartbeat"));
|
||||
lastCommandPollAt = System.currentTimeMillis();
|
||||
JSONObject commandEnvelope = client.nextVPNDiagnosticCommand(clusterId, deviceId, 5000);
|
||||
if (commandEnvelope != null) {
|
||||
handleCommand(client, clusterId, deviceId, commandEnvelope);
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
serviceState = "error: " + e.getMessage();
|
||||
try {
|
||||
Thread.sleep(3000);
|
||||
} catch (InterruptedException interrupted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCommand(RapApiClient client, String clusterId, String deviceId, JSONObject envelope) throws Exception {
|
||||
JSONObject command = envelope.optJSONObject("vpn_client_diagnostic_command");
|
||||
JSONObject payload = command == null ? envelope.optJSONObject("payload") : command.optJSONObject("payload");
|
||||
if (payload == null) {
|
||||
return;
|
||||
}
|
||||
String type = payload.optString("type", "");
|
||||
String result;
|
||||
if ("start_vpn".equals(type)) {
|
||||
result = startVPNFromSavedProfile();
|
||||
} else if ("stop_vpn".equals(type)) {
|
||||
Intent stopIntent = new Intent(this, RapVpnService.class);
|
||||
stopIntent.setAction(RapVpnService.ACTION_STOP);
|
||||
startService(stopIntent);
|
||||
result = "stop_vpn accepted";
|
||||
} else if ("http_get".equals(type)) {
|
||||
result = runHttpGet(payload.optString("url", "http://192.168.200.61:18080/"));
|
||||
} else if ("vpn_http_get".equals(type)) {
|
||||
result = runVPNHttpGet(payload.optString("url", "http://192.168.200.61:18080/"));
|
||||
} else if ("vpn_dns_lookup".equals(type)) {
|
||||
result = runVPNDNSLookup(payload.optString("host", "2ip.ru"));
|
||||
} else if ("open_url".equals(type)) {
|
||||
String url = payload.optString("url", "http://2ip.ru/");
|
||||
Intent open = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
|
||||
open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(open);
|
||||
result = "open_url accepted " + url;
|
||||
} else if ("vpn_stats".equals(type)) {
|
||||
result = collectVPNStats(client, clusterId);
|
||||
} else if ("full_vpn_test".equals(type)) {
|
||||
result = runFullVPNTest(client, clusterId, payload);
|
||||
} else if ("refresh_profile".equals(type)) {
|
||||
result = refreshProfile();
|
||||
} else {
|
||||
result = "unknown command " + type;
|
||||
}
|
||||
lastCommandType = type;
|
||||
lastCommandResult = result;
|
||||
lastCommandAt = System.currentTimeMillis();
|
||||
JSONObject report = statusPayload("command_result");
|
||||
report.put("command_type", type);
|
||||
report.put("command_result", result);
|
||||
client.reportVPNDiagnosticStatus(clusterId, deviceId, report);
|
||||
}
|
||||
|
||||
private String startVPNFromSavedProfile() {
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
String profileJson = prefs.getString(PREF_PROFILE_JSON, "");
|
||||
String backendUrl = prefs.getString("backend_url", "");
|
||||
String clusterId = prefs.getString("cluster_id", "");
|
||||
String vpnConnectionId = prefs.getString(PREF_VPN_CONNECTION_ID, "");
|
||||
if (profileJson.isEmpty() || backendUrl.isEmpty() || clusterId.isEmpty() || vpnConnectionId.isEmpty()) {
|
||||
return "start_vpn skipped: profile/backend/cluster/connection missing";
|
||||
}
|
||||
if (VpnService.prepare(this) != null) {
|
||||
Intent launcher = new Intent(this, TestVpnActivity.class);
|
||||
launcher.putExtra(TestVpnActivity.EXTRA_PROFILE_JSON, profileJson);
|
||||
launcher.putExtra(TestVpnActivity.EXTRA_BACKEND_URL, backendUrl);
|
||||
launcher.putExtra(TestVpnActivity.EXTRA_CLUSTER_ID, clusterId);
|
||||
launcher.putExtra(TestVpnActivity.EXTRA_VPN_CONNECTION_ID, vpnConnectionId);
|
||||
launcher.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(launcher);
|
||||
return "start_vpn permission required: opened vpn launcher " + vpnConnectionId;
|
||||
}
|
||||
Intent intent = new Intent(this, RapVpnService.class);
|
||||
intent.putExtra(RapVpnService.EXTRA_PROFILE_JSON, profileJson);
|
||||
intent.putExtra(RapVpnService.EXTRA_BACKEND_URL, backendUrl);
|
||||
intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, clusterId);
|
||||
intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, vpnConnectionId);
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
startForegroundService(intent);
|
||||
} else {
|
||||
startService(intent);
|
||||
}
|
||||
return "start_vpn accepted " + vpnConnectionId;
|
||||
}
|
||||
|
||||
private String refreshProfile() {
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
try {
|
||||
String refreshToken = new SecureTokenStore(this).get(PREF_REFRESH_TOKEN);
|
||||
if (refreshToken.isEmpty()) {
|
||||
return "refresh_profile skipped: refresh token missing";
|
||||
}
|
||||
RapApiClient client = new RapApiClient(normalizeBackendUrl(prefs.getString("backend_url", "")), this);
|
||||
RapApiClient.AuthContext auth = client.refresh(refreshToken);
|
||||
String organizationId = prefs.getString("organization_id", "");
|
||||
String clusterId = prefs.getString("cluster_id", "");
|
||||
String profileJson = client.vpnClientProfile(clusterId, organizationId, auth.userId, DEFAULT_ENTRY_NODE_ID);
|
||||
JSONObject root = new JSONObject(profileJson);
|
||||
JSONObject profile = root.getJSONObject("vpn_client_profile");
|
||||
String connectionId = profile.getJSONArray("connections").getJSONObject(0).getString("id");
|
||||
prefs.edit()
|
||||
.putString(PREF_USER_ID, auth.userId)
|
||||
.putString(PREF_DEVICE_ID, auth.deviceId)
|
||||
.putString(PREF_PROFILE_JSON, profileJson)
|
||||
.putString(PREF_VPN_CONNECTION_ID, connectionId)
|
||||
.apply();
|
||||
new SecureTokenStore(this).put(PREF_REFRESH_TOKEN, auth.refreshToken);
|
||||
return "refresh_profile ok " + connectionId;
|
||||
} catch (Exception e) {
|
||||
return "refresh_profile failed: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject statusPayload(String event) throws Exception {
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("event", event);
|
||||
payload.put("app_version", APP_VERSION);
|
||||
payload.put("service", "diagnostic");
|
||||
payload.put("user_id", prefs.getString(PREF_USER_ID, ""));
|
||||
payload.put("device_id", prefs.getString(PREF_DEVICE_ID, ""));
|
||||
payload.put("organization_id", prefs.getString("organization_id", ""));
|
||||
payload.put("vpn_connection_id", prefs.getString(PREF_VPN_CONNECTION_ID, ""));
|
||||
payload.put("backend_url", prefs.getString("backend_url", ""));
|
||||
payload.put("control_network_mode", controlNetworkMode);
|
||||
payload.put("profile_loaded", !prefs.getString(PREF_PROFILE_JSON, "").isEmpty());
|
||||
payload.put("runtime", runtimeSnapshot());
|
||||
payload.put("vpn_config", vpnConfigSnapshot());
|
||||
payload.put("service_state", serviceState);
|
||||
payload.put("last_result", lastCommandResult);
|
||||
payload.put("last_command_type", lastCommandType);
|
||||
payload.put("last_command_result", lastCommandResult);
|
||||
payload.put("last_command_at", lastCommandAt);
|
||||
payload.put("last_heartbeat_at", lastHeartbeatAt);
|
||||
payload.put("last_command_poll_at", lastCommandPollAt);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private String normalizeBackendUrl(String value) {
|
||||
String candidate = value == null ? "" : value.trim().replaceAll("/+$", "");
|
||||
if (candidate.isEmpty() || isLegacyControlPlaneUrl(candidate)) {
|
||||
return DEFAULT_BACKEND_URL;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private boolean isLegacyControlPlaneUrl(String value) {
|
||||
String lower = value.toLowerCase();
|
||||
return lower.equals("http://94.141.118.222:19191/api/v1")
|
||||
|| lower.equals("http://vpn.cin.su:19191/api/v1")
|
||||
|| lower.equals("http://192.168.200.61:18080/api/v1")
|
||||
|| lower.equals("http://docker-test.cin.su:18080/api/v1")
|
||||
|| lower.equals("http://docker-test.cin.su/api/v1")
|
||||
|| lower.equals("http://192.168.200.61/api/v1");
|
||||
}
|
||||
|
||||
private String collectVPNStats(RapApiClient client, String clusterId) {
|
||||
String connectionId = getSharedPreferences(PREFS, MODE_PRIVATE).getString(PREF_VPN_CONNECTION_ID, "");
|
||||
if (connectionId.isEmpty()) {
|
||||
return "vpn_stats skipped: connection missing";
|
||||
}
|
||||
try {
|
||||
JSONObject stats = client.vpnPacketStats(clusterId, connectionId);
|
||||
return "vpn_stats " + compact(stats.toString(), 900);
|
||||
} catch (Exception e) {
|
||||
return "vpn_stats failed: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private String runFullVPNTest(RapApiClient client, String clusterId, JSONObject payload) {
|
||||
String url = payload.optString("url", "http://2ip.ru/");
|
||||
int watchSeconds = payload.optInt("watch_seconds", 30);
|
||||
if (watchSeconds < 5) {
|
||||
watchSeconds = 5;
|
||||
}
|
||||
if (watchSeconds > 120) {
|
||||
watchSeconds = 120;
|
||||
}
|
||||
String connectionId = getSharedPreferences(PREFS, MODE_PRIVATE).getString(PREF_VPN_CONNECTION_ID, "");
|
||||
StringBuilder result = new StringBuilder();
|
||||
try {
|
||||
result.append(refreshProfile()).append(" | ");
|
||||
if (!connectionId.isEmpty()) {
|
||||
result.append("reset=").append(compact(client.resetVPNPacketQueues(clusterId, connectionId).toString(), 240)).append(" | ");
|
||||
}
|
||||
result.append(startVPNFromSavedProfile()).append(" | ");
|
||||
Thread.sleep(3000);
|
||||
Intent open = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
|
||||
open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(open);
|
||||
result.append("open_url=").append(url);
|
||||
long deadline = System.currentTimeMillis() + watchSeconds * 1000L;
|
||||
while (running && System.currentTimeMillis() < deadline) {
|
||||
Thread.sleep(5000);
|
||||
JSONObject report = statusPayload("full_vpn_test_watch");
|
||||
report.put("test_url", url);
|
||||
if (!connectionId.isEmpty()) {
|
||||
report.put("packet_stats", client.vpnPacketStats(clusterId, connectionId));
|
||||
}
|
||||
client.reportVPNDiagnosticStatus(clusterId, getSharedPreferences(PREFS, MODE_PRIVATE).getString(PREF_DEVICE_ID, ""), report);
|
||||
}
|
||||
if (!connectionId.isEmpty()) {
|
||||
result.append(" | stats=").append(compact(client.vpnPacketStats(clusterId, connectionId).toString(), 900));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
result.append(" | full_vpn_test failed: ").append(e.getClass().getSimpleName()).append(": ").append(e.getMessage());
|
||||
}
|
||||
return compact(result.toString(), 1200);
|
||||
}
|
||||
|
||||
private String compact(String value, int maxLength) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String compacted = value.replace('\n', ' ').replace('\r', ' ');
|
||||
if (compacted.length() <= maxLength) {
|
||||
return compacted;
|
||||
}
|
||||
return compacted.substring(0, Math.max(0, maxLength - 3)) + "...";
|
||||
}
|
||||
|
||||
private JSONObject runtimeSnapshot() throws Exception {
|
||||
SharedPreferences runtime = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE);
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("state", runtime.getString("state", ""));
|
||||
payload.put("message", runtime.getString("message", ""));
|
||||
payload.put("updated_at", runtime.getLong("updated_at", 0));
|
||||
payload.put("runtime_started_at", runtime.getLong("runtime_started_at", 0));
|
||||
payload.put("uplink_read", runtime.getLong("uplink_read", 0));
|
||||
payload.put("uplink_sent", runtime.getLong("uplink_sent", 0));
|
||||
payload.put("downlink_received", runtime.getLong("downlink_received", 0));
|
||||
payload.put("uplink_read_total", runtime.getLong("uplink_read_total", 0));
|
||||
payload.put("uplink_read_bytes", runtime.getLong("uplink_read_bytes", 0));
|
||||
payload.put("uplink_sent_total", runtime.getLong("uplink_sent_total", 0));
|
||||
payload.put("uplink_sent_bytes", runtime.getLong("uplink_sent_bytes", 0));
|
||||
payload.put("downlink_received_total", runtime.getLong("downlink_received_total", 0));
|
||||
payload.put("downlink_received_bytes", runtime.getLong("downlink_received_bytes", 0));
|
||||
payload.put("uplink_read_mbps", runtime.getFloat("uplink_read_mbps", 0f));
|
||||
payload.put("uplink_sent_mbps", runtime.getFloat("uplink_sent_mbps", 0f));
|
||||
payload.put("downlink_received_mbps", runtime.getFloat("downlink_received_mbps", 0f));
|
||||
payload.put("uplink_read_pps", runtime.getFloat("uplink_read_pps", 0f));
|
||||
payload.put("uplink_sent_pps", runtime.getFloat("uplink_sent_pps", 0f));
|
||||
payload.put("downlink_received_pps", runtime.getFloat("downlink_received_pps", 0f));
|
||||
payload.put("uplink_dropped_packets", runtime.getLong("uplink_dropped_packets", 0));
|
||||
payload.put("uplink_dropped_bytes", runtime.getLong("uplink_dropped_bytes", 0));
|
||||
payload.put("downlink_dropped_packets", runtime.getLong("downlink_dropped_packets", 0));
|
||||
payload.put("downlink_dropped_bytes", runtime.getLong("downlink_dropped_bytes", 0));
|
||||
payload.put("errors", runtime.getLong("errors", 0));
|
||||
payload.put("uplink", runtimePrefix(runtime, "uplink"));
|
||||
payload.put("uplink_sender", runtimePrefix(runtime, "uplink_sender"));
|
||||
payload.put("downlink", runtimePrefix(runtime, "downlink"));
|
||||
payload.put("relay", runtimePrefix(runtime, "relay"));
|
||||
payload.put("uplink_worker_count", runtime.getInt("uplink_worker_count", 0));
|
||||
payload.put("uplink_queue_depth_total", runtime.getInt("uplink_queue_depth_total", 0));
|
||||
payload.put("uplink_queue_depth_max", runtime.getInt("uplink_queue_depth_max", 0));
|
||||
payload.put("uplink_queue_depths", runtime.getString("uplink_queue_depths", ""));
|
||||
payload.put("uplink_queue_0_offers", runtime.getLong("uplink_queue_0_offers", 0));
|
||||
payload.put("uplink_queue_1_offers", runtime.getLong("uplink_queue_1_offers", 0));
|
||||
payload.put("uplink_queue_2_offers", runtime.getLong("uplink_queue_2_offers", 0));
|
||||
payload.put("uplink_queue_3_offers", runtime.getLong("uplink_queue_3_offers", 0));
|
||||
payload.put("uplink_queue_0_drops", runtime.getLong("uplink_queue_0_drops", 0));
|
||||
payload.put("uplink_queue_1_drops", runtime.getLong("uplink_queue_1_drops", 0));
|
||||
payload.put("uplink_queue_2_drops", runtime.getLong("uplink_queue_2_drops", 0));
|
||||
payload.put("uplink_queue_3_drops", runtime.getLong("uplink_queue_3_drops", 0));
|
||||
payload.put("uplink_sender_worker_packets_0", runtime.getLong("uplink_sender_worker_packets_0", 0));
|
||||
payload.put("uplink_sender_worker_packets_1", runtime.getLong("uplink_sender_worker_packets_1", 0));
|
||||
payload.put("uplink_sender_worker_packets_2", runtime.getLong("uplink_sender_worker_packets_2", 0));
|
||||
payload.put("uplink_sender_worker_packets_3", runtime.getLong("uplink_sender_worker_packets_3", 0));
|
||||
payload.put("uplink_sender_worker_errors_0", runtime.getLong("uplink_sender_worker_errors_0", 0));
|
||||
payload.put("uplink_sender_worker_errors_1", runtime.getLong("uplink_sender_worker_errors_1", 0));
|
||||
payload.put("uplink_sender_worker_errors_2", runtime.getLong("uplink_sender_worker_errors_2", 0));
|
||||
payload.put("uplink_sender_worker_errors_3", runtime.getLong("uplink_sender_worker_errors_3", 0));
|
||||
payload.put("uplink_queue_depth", runtime.getInt("uplink_queue_depth", 0));
|
||||
payload.put("downlink_restarts", runtime.getLong("downlink_restarts", 0));
|
||||
return payload;
|
||||
}
|
||||
|
||||
private JSONObject vpnConfigSnapshot() throws Exception {
|
||||
SharedPreferences runtime = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE);
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("vpn_address", runtime.getString("vpn_address", ""));
|
||||
payload.put("dns_servers", runtime.getString("dns_servers", ""));
|
||||
payload.put("routes", runtime.getString("routes", ""));
|
||||
payload.put("full_tunnel", runtime.getBoolean("full_tunnel", false));
|
||||
return payload;
|
||||
}
|
||||
|
||||
private JSONObject runtimePrefix(SharedPreferences runtime, String prefix) throws Exception {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("state", runtime.getString(prefix + "_state", ""));
|
||||
payload.put("message", runtime.getString(prefix + "_message", ""));
|
||||
payload.put("updated_at", runtime.getLong(prefix + "_updated_at", 0));
|
||||
payload.put("packets", runtime.getLong(prefix + "_packets", 0));
|
||||
payload.put("bytes", runtime.getLong(prefix + "_bytes", 0));
|
||||
payload.put("errors", runtime.getLong(prefix + "_errors", 0));
|
||||
payload.put("error_type", runtime.getString(prefix + "_error_type", ""));
|
||||
payload.put("thread_alive", runtime.getBoolean(prefix + "_thread_alive", false));
|
||||
payload.put("rate_mbps", runtime.getFloat(prefix + "_rate_mbps", 0f));
|
||||
payload.put("rate_pps", runtime.getFloat(prefix + "_rate_pps", 0f));
|
||||
return payload;
|
||||
}
|
||||
|
||||
private String runHttpGet(String target) {
|
||||
try {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(target).openConnection();
|
||||
connection.setConnectTimeout(15000);
|
||||
connection.setReadTimeout(15000);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
int code = connection.getResponseCode();
|
||||
connection.disconnect();
|
||||
return "http_get " + target + " -> HTTP " + code;
|
||||
} catch (Exception e) {
|
||||
return "http_get " + target + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private String runVPNHttpGet(String target) {
|
||||
try {
|
||||
Network vpn = vpnNetwork();
|
||||
if (vpn == null) {
|
||||
return "vpn_http_get " + target + " -> vpn network not found";
|
||||
}
|
||||
URL url = new URL(target);
|
||||
HttpURLConnection connection;
|
||||
String resolved = "";
|
||||
if ("http".equalsIgnoreCase(url.getProtocol()) && !isIPv4Literal(url.getHost())) {
|
||||
resolved = firstManualVPNAddress(vpn, url.getHost());
|
||||
}
|
||||
if (!resolved.isEmpty()) {
|
||||
URL resolvedURL = new URL(url.getProtocol(), resolved, url.getPort(), url.getFile());
|
||||
connection = (HttpURLConnection) vpn.openConnection(resolvedURL);
|
||||
connection.setRequestProperty("Host", hostHeader(url));
|
||||
} else {
|
||||
connection = (HttpURLConnection) vpn.openConnection(url);
|
||||
}
|
||||
try {
|
||||
connection.setConnectTimeout(15000);
|
||||
connection.setReadTimeout(15000);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
int code = connection.getResponseCode();
|
||||
connection.disconnect();
|
||||
return "vpn_http_get " + target + " -> HTTP " + code;
|
||||
} catch (UnknownHostException e) {
|
||||
String fallbackResolved = firstManualVPNAddress(vpn, url.getHost());
|
||||
if (fallbackResolved.isEmpty() || !"http".equalsIgnoreCase(url.getProtocol())) {
|
||||
throw e;
|
||||
}
|
||||
URL resolvedURL = new URL(url.getProtocol(), fallbackResolved, url.getPort(), url.getFile());
|
||||
connection = (HttpURLConnection) vpn.openConnection(resolvedURL);
|
||||
connection.setRequestProperty("Host", hostHeader(url));
|
||||
connection.setConnectTimeout(15000);
|
||||
connection.setReadTimeout(15000);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
int code = connection.getResponseCode();
|
||||
connection.disconnect();
|
||||
return "vpn_http_get " + target + " -> HTTP " + code;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return "vpn_http_get " + target + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isIPv4Literal(String host) {
|
||||
if (host == null) {
|
||||
return false;
|
||||
}
|
||||
String[] parts = host.split("\\.");
|
||||
if (parts.length != 4) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
for (String part : parts) {
|
||||
int value = Integer.parseInt(part);
|
||||
if (value < 0 || value > 255) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String runVPNDNSLookup(String host) {
|
||||
try {
|
||||
Network vpn = vpnNetwork();
|
||||
if (vpn == null) {
|
||||
return "vpn_dns_lookup " + host + " -> vpn network not found";
|
||||
}
|
||||
StringBuilder result = new StringBuilder();
|
||||
try {
|
||||
InetAddress[] system = vpn.getAllByName(host);
|
||||
result.append("system=");
|
||||
appendAddresses(result, system);
|
||||
} catch (Exception e) {
|
||||
result.append("system=").append(e.getClass().getSimpleName()).append(":").append(e.getMessage());
|
||||
}
|
||||
String manual = manualVPNDNSLookup(vpn, host);
|
||||
result.append(" manual=").append(manual);
|
||||
return "vpn_dns_lookup " + host + " -> " + result;
|
||||
} catch (Exception e) {
|
||||
return "vpn_dns_lookup " + host + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private String firstManualVPNAddress(Network vpn, String host) {
|
||||
String result = manualVPNDNSLookup(vpn, host);
|
||||
if (result.startsWith("ok:")) {
|
||||
String addresses = result.substring(3);
|
||||
int comma = addresses.indexOf(',');
|
||||
return comma >= 0 ? addresses.substring(0, comma) : addresses;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String manualVPNDNSLookup(Network vpn, String host) {
|
||||
String dnsServers = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE).getString("dns_servers", "");
|
||||
if (dnsServers.isEmpty()) {
|
||||
return "skipped:no_dns_servers";
|
||||
}
|
||||
String dnsServer = dnsServers.split(",", 2)[0].trim();
|
||||
if (dnsServer.isEmpty()) {
|
||||
return "skipped:no_dns_servers";
|
||||
}
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
vpn.bindSocket(socket);
|
||||
socket.setSoTimeout(5000);
|
||||
byte[] query = buildDNSQuery(host);
|
||||
DatagramPacket packet = new DatagramPacket(query, query.length, InetAddress.getByName(dnsServer), 53);
|
||||
socket.send(packet);
|
||||
byte[] response = new byte[512];
|
||||
DatagramPacket answer = new DatagramPacket(response, response.length);
|
||||
socket.receive(answer);
|
||||
List<String> addresses = parseDNSAResponse(response, answer.getLength());
|
||||
if (addresses.isEmpty()) {
|
||||
return "empty:" + dnsServer;
|
||||
}
|
||||
return "ok:" + String.join(",", addresses);
|
||||
} catch (SocketTimeoutException e) {
|
||||
return "timeout:" + dnsServer;
|
||||
} catch (Exception e) {
|
||||
return e.getClass().getSimpleName() + ":" + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] buildDNSQuery(String host) throws Exception {
|
||||
byte[] out = new byte[512];
|
||||
int id = new Random().nextInt(0xffff);
|
||||
out[0] = (byte) ((id >> 8) & 0xff);
|
||||
out[1] = (byte) (id & 0xff);
|
||||
out[2] = 0x01;
|
||||
out[5] = 0x01;
|
||||
int offset = 12;
|
||||
for (String label : host.split("\\.")) {
|
||||
byte[] bytes = label.getBytes("UTF-8");
|
||||
out[offset++] = (byte) bytes.length;
|
||||
System.arraycopy(bytes, 0, out, offset, bytes.length);
|
||||
offset += bytes.length;
|
||||
}
|
||||
out[offset++] = 0;
|
||||
out[offset++] = 0;
|
||||
out[offset++] = 1;
|
||||
out[offset++] = 0;
|
||||
out[offset++] = 1;
|
||||
byte[] query = new byte[offset];
|
||||
System.arraycopy(out, 0, query, 0, offset);
|
||||
return query;
|
||||
}
|
||||
|
||||
private List<String> parseDNSAResponse(byte[] packet, int length) {
|
||||
List<String> addresses = new ArrayList<>();
|
||||
if (length < 12) {
|
||||
return addresses;
|
||||
}
|
||||
int qd = u16(packet, 4);
|
||||
int an = u16(packet, 6);
|
||||
int offset = 12;
|
||||
for (int i = 0; i < qd; i++) {
|
||||
offset = skipDNSName(packet, length, offset);
|
||||
offset += 4;
|
||||
if (offset > length) {
|
||||
return addresses;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < an && offset < length; i++) {
|
||||
offset = skipDNSName(packet, length, offset);
|
||||
if (offset + 10 > length) {
|
||||
return addresses;
|
||||
}
|
||||
int type = u16(packet, offset);
|
||||
int cls = u16(packet, offset + 2);
|
||||
int rdLen = u16(packet, offset + 8);
|
||||
offset += 10;
|
||||
if (type == 1 && cls == 1 && rdLen == 4 && offset + 4 <= length) {
|
||||
addresses.add((packet[offset] & 0xff) + "." + (packet[offset + 1] & 0xff) + "." + (packet[offset + 2] & 0xff) + "." + (packet[offset + 3] & 0xff));
|
||||
}
|
||||
offset += rdLen;
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
private int skipDNSName(byte[] packet, int length, int offset) {
|
||||
while (offset < length) {
|
||||
int value = packet[offset] & 0xff;
|
||||
offset++;
|
||||
if (value == 0) {
|
||||
break;
|
||||
}
|
||||
if ((value & 0xc0) == 0xc0) {
|
||||
offset++;
|
||||
break;
|
||||
}
|
||||
offset += value;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
private int u16(byte[] packet, int offset) {
|
||||
if (packet == null || offset + 1 >= packet.length) {
|
||||
return 0;
|
||||
}
|
||||
return ((packet[offset] & 0xff) << 8) | (packet[offset + 1] & 0xff);
|
||||
}
|
||||
|
||||
private void appendAddresses(StringBuilder result, InetAddress[] addresses) {
|
||||
if (addresses == null || addresses.length == 0) {
|
||||
result.append("empty");
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < addresses.length; i++) {
|
||||
if (i > 0) {
|
||||
result.append(",");
|
||||
}
|
||||
result.append(addresses[i].getHostAddress());
|
||||
}
|
||||
}
|
||||
|
||||
private String hostHeader(URL url) {
|
||||
if (url.getPort() > 0) {
|
||||
return url.getHost() + ":" + url.getPort();
|
||||
}
|
||||
return url.getHost();
|
||||
}
|
||||
|
||||
private Network vpnNetwork() {
|
||||
ConnectivityManager connectivity = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE);
|
||||
if (connectivity == null) {
|
||||
return null;
|
||||
}
|
||||
for (Network network : connectivity.getAllNetworks()) {
|
||||
NetworkCapabilities capabilities = connectivity.getNetworkCapabilities(network);
|
||||
if (capabilities != null && capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
||||
return network;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Notification notification() {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "RAP VPN diagnostics", NotificationManager.IMPORTANCE_LOW);
|
||||
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||
if (manager != null) {
|
||||
manager.createNotificationChannel(channel);
|
||||
}
|
||||
}
|
||||
Notification.Builder builder = Build.VERSION.SDK_INT >= 26 ? new Notification.Builder(this, CHANNEL_ID) : new Notification.Builder(this);
|
||||
return builder
|
||||
.setContentTitle("RAP VPN diagnostics")
|
||||
.setContentText("Diagnostic channel is active")
|
||||
.setSmallIcon(android.R.drawable.stat_sys_upload_done)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
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,40 @@
|
||||
package su.cin.rapvpn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.widget.TextView;
|
||||
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
|
||||
public class TestTrafficActivity extends Activity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
TextView text = new TextView(this);
|
||||
text.setText("traffic test starting");
|
||||
setContentView(text);
|
||||
String url = getIntent().getStringExtra("url");
|
||||
if (url == null || url.isEmpty()) {
|
||||
url = "http://192.168.200.61:18080/";
|
||||
}
|
||||
String target = url;
|
||||
new Thread(() -> runRequest(text, target), "rap-test-traffic").start();
|
||||
}
|
||||
|
||||
private void runRequest(TextView text, 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;
|
||||
runOnUiThread(() -> text.setText(finalResult));
|
||||
}
|
||||
}
|
||||
@@ -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,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>
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id "com.android.application" version "8.7.3" apply false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
sdk.dir=C:\Android\sdk
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user