Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,6 @@ jobs:

- name: Build project
run: ./gradlew assembleDebug
- name: Checks
run: ./gradlew tomlCheck ktlintCheck detektCheck checkSortDependencies projectHealth
- name: Run tests
run: ./gradlew test
- name: Build APK and AAB
Expand Down
7 changes: 7 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,22 @@ dependencies {
implementation(libs.jackson.databind)
implementation(libs.material)
implementation(libs.zxing)
implementation(libs.gson)

compileOnly(libs.androidlombock)

annotationProcessor(libs.androidlombock)
annotationProcessor(libs.androidx.room.compiler)

testImplementation(libs.assertj.core)
testImplementation(libs.junit)

androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.androidx.rules)
androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.testing)
androidTestImplementation(libs.assertj.core)
}
java {
toolchain {
Expand Down
86 changes: 86 additions & 0 deletions app/src/androidTest/java/org/fptn/vpn/database/dao/SniDaoTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package org.fptn.vpn.database.dao;

import static org.assertj.core.api.Assertions.assertThat;

import android.content.Context;

import androidx.arch.core.executor.testing.InstantTaskExecutorRule;
import androidx.room.Room;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;

import org.fptn.vpn.database.AppDatabase;
import org.fptn.vpn.database.entity.SniEntity;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.util.Arrays;
import java.util.List;

@RunWith(AndroidJUnit4.class)
public class SniDaoTest {

@Rule
public InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule();

private AppDatabase db;
private SniDao sniDao;

@Before
public void createDb() {
Context context = ApplicationProvider.getApplicationContext();
db = Room.inMemoryDatabaseBuilder(context, AppDatabase.class).build();
sniDao = db.sniDAO();
}

@After
public void closeDb() {
db.close();
}

@Test
public void insertAndGetAllSni() {
SniEntity sni1 = new SniEntity("sni1", false);
SniEntity sni2 = new SniEntity("sni2", false);
List<SniEntity> snis = Arrays.asList(sni1, sni2);

sniDao.insertAll(snis);

List<SniEntity> allSni = sniDao.getAll();

assertThat(allSni).hasSize(2);
assertThat(allSni.get(0).getSni()).isEqualTo("sni1");
assertThat(allSni.get(1).getSni()).isEqualTo("sni2");
}

@Test
public void insertDuplicateSni() {
SniEntity sni1 = new SniEntity("sni1", false);
SniEntity sni2 = new SniEntity("sni1", false); // Duplicate
List<SniEntity> snis = Arrays.asList(sni1, sni2);

sniDao.insertAll(snis);

List<SniEntity> allSni = sniDao.getAll();

assertThat(allSni).hasSize(1);
assertThat(allSni.get(0).getSni()).isEqualTo("sni1");
}

@Test
public void deleteAll() {
SniEntity sni1 = new SniEntity("sni1", false);
List<SniEntity> snis = List.of(sni1);

sniDao.insertAll(snis);
sniDao.deleteAll();

List<SniEntity> allSni = sniDao.getAll();

assertThat(allSni).isEmpty();
}

}
7 changes: 7 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@
android:value="android.service.quicksettings.CATEGORY_CONNECTIVITY" />
</service>

<service
android:name="org.fptn.vpn.services.snichecker.SniCheckerService"
android:exported="false"
android:foregroundServiceType="systemExempted"
android:stopWithTask="false"
tools:ignore="ForegroundServicePermission"> <!--add this to turn off warning-->
</service>
</application>

</manifest>
1 change: 1 addition & 0 deletions app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ add_library(
src/wrappers/wrapper_websocket_client/wrapper_websocket_client.cpp
src/wrappers/wrapper_https_client/wrapper_https_client.h
src/wrappers/wrapper_https_client/wrapper_https_client.cpp
src/sni_checker.cpp
src/https_client.cpp
src/websocket_client.cpp
src/wrappers/utils/utils.h
Expand Down
156 changes: 156 additions & 0 deletions app/src/main/cpp/src/sni_checker.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*=============================================================================
Copyright (c) 2024-2025 Stas Skokov

Distributed under the MIT License (https://opensource.org/licenses/MIT)
=============================================================================*/

#include <chrono>
#include <jni.h>
#include <memory>
#include <string>

#include "fptn-protocol-lib/https/api_client/api_client.h"
#include "wrappers/utils/utils.h"

class NativeSniChecker {
public:
NativeSniChecker(const std::string& host,
int port,
const std::string& md5_fingerprint,
const std::string& censorship_strategy)
: host_(host), port_(port), md5_fingerprint_(md5_fingerprint) {
fptn::protocol::https::CensorshipStrategy strategy =
fptn::protocol::https::CensorshipStrategy::kSni;

if (censorship_strategy == "OBFUSCATION") {
strategy = fptn::protocol::https::CensorshipStrategy::kTlsObfuscator;
} else if (censorship_strategy == "SNI-REALITY") {
strategy = fptn::protocol::https::CensorshipStrategy::kSniRealityMode;
}
client_ = std::make_unique<fptn::protocol::https::ApiClient>(
host_, port_, strategy);
SPDLOG_INFO("NativeSniChecker created for {}:{}", host, port);
}

bool checkSni(const std::string& sni) {
SPDLOG_INFO("=== Starting SNI check for: {} ===", sni);

try {
// Step 1: Test handshake
SPDLOG_INFO("Step 1/2: Testing handshake for SNI: {}", sni);

fptn::protocol::https::ApiClient client(host_, port_, sni,
md5_fingerprint_, fptn::protocol::https::CensorshipStrategy::kSni);

const auto handshake_start = std::chrono::steady_clock::now();
const bool handshake_success = client.TestHandshake(10);
const auto handshake_end = std::chrono::steady_clock::now();
const auto handshake_ms =
std::chrono::duration_cast<std::chrono::milliseconds>(
handshake_end - handshake_start)
.count();

if (!handshake_success) {
SPDLOG_WARN("Step 1/2 FAILED: Handshake failed for SNI: {} after {} ms",
sni, handshake_ms);
return false;
}
SPDLOG_INFO("Step 1/2 SUCCESS: Handshake completed for SNI: {} in {} ms",
sni, handshake_ms);

// Step 2: Download test file
SPDLOG_INFO("Step 2/2: Downloading test file for SNI: {}", sni);

auto download_start = std::chrono::steady_clock::now();

fptn::protocol::https::ApiClient download_client(host_, port_, sni,
md5_fingerprint_, fptn::protocol::https::CensorshipStrategy::kSni);

const auto response = download_client.Get("/api/v1/test/file.bin", 15);

const auto download_end = std::chrono::steady_clock::now();
const auto download_ms =
std::chrono::duration_cast<std::chrono::milliseconds>(
download_end - download_start)
.count();

if (response.code == 200) {
SPDLOG_INFO(
"Step 2/2 SUCCESS: File downloaded for SNI: {} in {} ms, size: {} "
"bytes",
sni, download_ms, response.body.size());
SPDLOG_INFO("=== SNI check COMPLETED SUCCESSFULLY for: {} ===", sni);
return true;
} else {
SPDLOG_WARN(
"Step 2/2 FAILED: Download failed for SNI: {} - HTTP code: {}, "
"error: {}",
sni, response.code, response.errmsg);
SPDLOG_INFO("=== SNI check FAILED for: {} (download error) ===", sni);
return false;
}

} catch (const std::exception& e) {
SPDLOG_ERROR("=== SNI check EXCEPTION for {}: {} ===", sni, e.what());
return false;
}
}

private:
const std::string host_;
const int port_;
const std::string md5_fingerprint_;

std::unique_ptr<fptn::protocol::https::ApiClient> client_;
};

extern "C" {

JNIEXPORT jlong JNICALL
Java_org_fptn_vpn_services_snichecker_SniChecker_nativeCreate(JNIEnv* env,
jobject thiz,
jstring host_param,
jint port_param,
jstring md5_fingerprint_param,
jstring censorship_strategy_param) {
fptn::wrapper::init_logger();

const char* host_str = env->GetStringUTFChars(host_param, nullptr);
const char* md5_str = env->GetStringUTFChars(md5_fingerprint_param, nullptr);
const char* strategy_str =
env->GetStringUTFChars(censorship_strategy_param, nullptr);

auto* checker = new NativeSniChecker(std::string(host_str), (int)port_param,
std::string(md5_str ? md5_str : ""), std::string(strategy_str));

env->ReleaseStringUTFChars(host_param, host_str);
env->ReleaseStringUTFChars(md5_fingerprint_param, md5_str);
env->ReleaseStringUTFChars(censorship_strategy_param, strategy_str);

return reinterpret_cast<jlong>(checker);
}

JNIEXPORT jboolean JNICALL
Java_org_fptn_vpn_services_snichecker_SniChecker_nativeCheckSni(
JNIEnv* env, jobject thiz, jlong native_handle, jstring sni_param) {
auto* checker = reinterpret_cast<NativeSniChecker*>(native_handle);
if (!checker) {
SPDLOG_ERROR("NativeSniChecker handle is null");
return JNI_FALSE;
}

const char* sni_str = env->GetStringUTFChars(sni_param, nullptr);
bool result = checker->checkSni(std::string(sni_str));
env->ReleaseStringUTFChars(sni_param, sni_str);

return result ? JNI_TRUE : JNI_FALSE;
}

JNIEXPORT void JNICALL
Java_org_fptn_vpn_services_snichecker_SniChecker_nativeDestroy(
JNIEnv* env, jobject thiz, jlong native_handle) {
auto* checker = reinterpret_cast<NativeSniChecker*>(native_handle);
delete checker;
}

} // extern "C"
6 changes: 5 additions & 1 deletion app/src/main/java/org/fptn/vpn/database/AppDatabase.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@

import org.fptn.vpn.database.dao.AppInfoDAO;
import org.fptn.vpn.database.dao.ServerDAO;
import org.fptn.vpn.database.dao.SniDao;
import org.fptn.vpn.database.entity.AppInfoEntity;
import org.fptn.vpn.database.entity.ServerEntity;
import org.fptn.vpn.database.entity.SniEntity;

@Database(entities = {ServerEntity.class, AppInfoEntity.class}, version = 1, exportSchema = false)
@Database(entities = {ServerEntity.class, AppInfoEntity.class, SniEntity.class}, version = 2, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase {

public static final String FPTN_DATABASE = "FptnDatabase";
Expand All @@ -20,6 +22,8 @@ public abstract class AppDatabase extends RoomDatabase {

public abstract AppInfoDAO appInfoDAO();

public abstract SniDao sniDAO();

private static AppDatabase instance;

public static synchronized AppDatabase getInstance(Context context) {
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/org/fptn/vpn/database/dao/ServerDAO.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public interface ServerDAO {
@Query("SELECT * FROM server_table WHERE censured = :censured")
List<ServerEntity> getServerList(boolean censured);

@Query("SELECT * FROM server_table WHERE censured = :censured")
ListenableFuture<List<ServerEntity>> getServerListAsync(boolean censured);

@Query("UPDATE server_table SET selected = CASE WHEN id = :id THEN 1 ELSE 0 END")
void setSelected(int id);

Expand All @@ -43,4 +46,6 @@ default void deleteAndInsert(List<ServerEntity> servers) {
insertAll(servers);
}

@Query("SELECT * FROM server_table WHERE id = :serverId")
ServerEntity getById(int serverId);
}
44 changes: 44 additions & 0 deletions app/src/main/java/org/fptn/vpn/database/dao/SniDao.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package org.fptn.vpn.database.dao;

import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;

import com.google.common.util.concurrent.ListenableFuture;

import org.fptn.vpn.database.entity.SniEntity;

import java.util.List;

@Dao
public interface SniDao {

@Query("SELECT * FROM sni_table")
List<SniEntity> getAll();

@Query("SELECT * FROM sni_table where checked = 0")
List<SniEntity> getAllUnchecked();

@Insert(onConflict = OnConflictStrategy.REPLACE)
ListenableFuture<Void> insertAll(List<SniEntity> sniList);

@Insert(onConflict = OnConflictStrategy.REPLACE)
void insert(SniEntity sni);

@Query("DELETE FROM sni_table")
void deleteAll();

@Query("SELECT COUNT(*) FROM sni_table")
int count();

@Query("SELECT COUNT(*) FROM sni_table where checked = 0")
int countUnchecked();

@Query("SELECT * FROM sni_table where checked = 0 limit :limit")
List<SniEntity> getUnchecked(int limit);

@Query("UPDATE sni_table SET checked = 0")
void resetAll();

}
Loading