diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 3a72c0b9..3317e9c2 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b008e820..f09614fe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -136,9 +136,15 @@ dependencies { 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 { diff --git a/app/src/androidTest/java/org/fptn/vpn/database/dao/SniDaoTest.java b/app/src/androidTest/java/org/fptn/vpn/database/dao/SniDaoTest.java new file mode 100644 index 00000000..7d8c463b --- /dev/null +++ b/app/src/androidTest/java/org/fptn/vpn/database/dao/SniDaoTest.java @@ -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 snis = Arrays.asList(sni1, sni2); + + sniDao.insertAll(snis); + + List 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 snis = Arrays.asList(sni1, sni2); + + sniDao.insertAll(snis); + + List 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 snis = List.of(sni1); + + sniDao.insertAll(snis); + sniDao.deleteAll(); + + List allSni = sniDao.getAll(); + + assertThat(allSni).isEmpty(); + } + +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2bd60cf7..6ff68f35 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,7 +4,8 @@ android:noHistory="true" tools:ignore="LockedOrientationActivity"> - @@ -13,7 +14,8 @@ - + + \ No newline at end of file diff --git a/app/src/main/java/org/fptn/vpn/database/AppDatabase.java b/app/src/main/java/org/fptn/vpn/database/AppDatabase.java index c9bc265b..5db6a815 100644 --- a/app/src/main/java/org/fptn/vpn/database/AppDatabase.java +++ b/app/src/main/java/org/fptn/vpn/database/AppDatabase.java @@ -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"; @@ -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) { diff --git a/app/src/main/java/org/fptn/vpn/database/dao/ServerDAO.java b/app/src/main/java/org/fptn/vpn/database/dao/ServerDAO.java index 27ced98f..19e7c15e 100644 --- a/app/src/main/java/org/fptn/vpn/database/dao/ServerDAO.java +++ b/app/src/main/java/org/fptn/vpn/database/dao/ServerDAO.java @@ -25,6 +25,9 @@ public interface ServerDAO { @Query("SELECT * FROM server_table WHERE censured = :censured") List getServerList(boolean censured); + @Query("SELECT * FROM server_table WHERE censured = :censured") + ListenableFuture> getServerListAsync(boolean censured); + @Query("UPDATE server_table SET selected = CASE WHEN id = :id THEN 1 ELSE 0 END") void setSelected(int id); @@ -43,4 +46,6 @@ default void deleteAndInsert(List servers) { insertAll(servers); } + @Query("SELECT * FROM server_table WHERE id = :serverId") + ServerEntity getById(int serverId); } diff --git a/app/src/main/java/org/fptn/vpn/database/dao/SniDao.java b/app/src/main/java/org/fptn/vpn/database/dao/SniDao.java new file mode 100644 index 00000000..e53478ee --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/database/dao/SniDao.java @@ -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 getAll(); + + @Query("SELECT * FROM sni_table where checked = 0") + List getAllUnchecked(); + + @Insert(onConflict = OnConflictStrategy.REPLACE) + ListenableFuture insertAll(List 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 getUnchecked(int limit); + + @Query("UPDATE sni_table SET checked = 0") + void resetAll(); + +} diff --git a/app/src/main/java/org/fptn/vpn/database/entity/SniEntity.java b/app/src/main/java/org/fptn/vpn/database/entity/SniEntity.java new file mode 100644 index 00000000..578d158f --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/database/entity/SniEntity.java @@ -0,0 +1,24 @@ +package org.fptn.vpn.database.entity; + +import androidx.annotation.NonNull; +import androidx.room.Entity; +import androidx.room.PrimaryKey; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity(tableName = "sni_table") +public class SniEntity { + + @PrimaryKey + @NonNull + private String sni; + + private boolean checked = false; +} diff --git a/app/src/main/java/org/fptn/vpn/enums/ConnectionState.java b/app/src/main/java/org/fptn/vpn/enums/ConnectionState.java index 2376db95..95eb90c6 100644 --- a/app/src/main/java/org/fptn/vpn/enums/ConnectionState.java +++ b/app/src/main/java/org/fptn/vpn/enums/ConnectionState.java @@ -3,6 +3,7 @@ import java.util.Set; public enum ConnectionState { + SEARCH_SNI, DISCONNECTED, CONNECTING, CONNECTED, @@ -11,7 +12,8 @@ public enum ConnectionState { private final static Set ACTIVE_STATES = Set.of( CONNECTING, CONNECTED, - RECONNECTING + RECONNECTING, + SEARCH_SNI ); public boolean isActiveState() { diff --git a/app/src/main/java/org/fptn/vpn/services/snichecker/SniChecker.java b/app/src/main/java/org/fptn/vpn/services/snichecker/SniChecker.java new file mode 100644 index 00000000..6a9fe894 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/services/snichecker/SniChecker.java @@ -0,0 +1,36 @@ +package org.fptn.vpn.services.snichecker; + +import android.util.Log; + +import org.fptn.vpn.database.entity.ServerEntity; +import org.fptn.vpn.enums.BypassCensorshipMethod; + +import java.util.Random; + +public class SniChecker { + private final String TAG = getClass().getSimpleName(); + private final ServerEntity selectedServer; + private final BypassCensorshipMethod bypassCensorshipMethod; + + public final Random RANDOM = new Random(); + + public SniChecker(ServerEntity selectedServer, BypassCensorshipMethod bypassCensorshipMethod) { + this.selectedServer = selectedServer; + this.bypassCensorshipMethod = bypassCensorshipMethod; + } + + public boolean checkSni(String sni) { + Log.d(TAG, "checkSni: " + sni); + + // todo: replace with real check + try { + long sleepTime = RANDOM.nextInt(1000) + 100; + Thread.sleep(sleepTime); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + + return RANDOM.nextInt(1000) > 950; + } +} diff --git a/app/src/main/java/org/fptn/vpn/services/snichecker/SniCheckerService.java b/app/src/main/java/org/fptn/vpn/services/snichecker/SniCheckerService.java new file mode 100644 index 00000000..7ad04f08 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/services/snichecker/SniCheckerService.java @@ -0,0 +1,358 @@ +package org.fptn.vpn.services.snichecker; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.pm.ServiceInfo; +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.os.PowerManager; +import android.util.Log; +import android.util.Pair; + +import androidx.lifecycle.MutableLiveData; + +import org.fptn.vpn.R; +import org.fptn.vpn.core.common.Constants; +import org.fptn.vpn.database.AppDatabase; +import org.fptn.vpn.database.entity.ServerEntity; +import org.fptn.vpn.database.entity.SniEntity; +import org.fptn.vpn.enums.BypassCensorshipMethod; +import org.fptn.vpn.utils.NotificationUtils; +import org.fptn.vpn.utils.SharedPrefUtils; +import org.fptn.vpn.views.bypassmethod.BypassMethodsActivity; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import lombok.Getter; + +public class SniCheckerService extends Service { + private static final String TAG = SniCheckerService.class.getSimpleName(); + + public static final String SELECTED_SERVER = "SELECTED_SERVER"; + public static final String RESET_CHECKED_EXTRA = "RESET_CHECKED"; + public static final String BYPASS_METHOD = "BYPASS_METHOD"; + + public static final String ACTION_START = "SniCheckerService:START"; + public static final String ACTION_STOP = "SniCheckerService:STOP"; + public static final String ACTION_BIND = "SniCheckerService:BIND"; + public static final String SNI_CHECKER_POWER_LOCK = "SniCheckerService::POWER_LOCK"; + + public static final int SNI_BATCH_SIZE = 25; + + @Getter + private static final MutableLiveData staticServiceState = new MutableLiveData<>(SniCheckerServiceState.INACTIVE); + + @Getter + private final MutableLiveData serviceState = new MutableLiveData<>(SniCheckerServiceState.INACTIVE); + + @Getter + private final MutableLiveData currentSniInfo = new MutableLiveData<>(); + + @Getter + private String foundedSni = null; + + @Getter + private final MutableLiveData> currentProgress = new MutableLiveData<>(); + + @Getter + private final MutableLiveData selectedServer = new MutableLiveData<>(ServerEntity.AUTO); + + @Getter + private BypassCensorshipMethod bypassCensorshipMethod = BypassCensorshipMethod.SNI_REALITY; + + // Pending Intent for launch byPassMethodActivity when notification tapped + private PendingIntent launchActivityPendingIntent; + + // Pending Intent to stop sni checking from notification + private PendingIntent stopPendingIntent; + + private final ExecutorService executorService = Executors.newSingleThreadExecutor(); + + private final AppDatabase appDatabase = AppDatabase.getInstance(this); + + private PowerManager.WakeLock wakeLock; + + // todo: + // 4) not allow run vpn if sni checking in progress. + + /* Just in case we need to bind! */ + public static void bindService(Context context, ServiceConnection connection) { + Intent intent = new Intent(context, SniCheckerService.class); + intent.setAction(ACTION_BIND); + context.bindService(intent, connection, BIND_AUTO_CREATE); + } + + private final IBinder binder = new LocalBinder(); + + public class LocalBinder extends Binder { + public SniCheckerService getService() { + return SniCheckerService.this; + } + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + /* JUST in case END */ + + @Override + public void onCreate() { + super.onCreate(); + + // Configure notification channels + NotificationUtils.configureNotificationChannel(this); + + // Pending Intent for launch byPassMethodActivity when notification tapped + launchActivityPendingIntent = PendingIntent.getActivity(this, 0, + new Intent(this, BypassMethodsActivity.class), + PendingIntent.FLAG_IMMUTABLE); + + // Pending Intent to stop sni checking from notification + stopPendingIntent = PendingIntent.getService(this, 0, + new Intent(this, SniCheckerService.class) + .setAction(SniCheckerService.ACTION_STOP), + PendingIntent.FLAG_IMMUTABLE); + + + serviceState.observeForever(staticServiceState::postValue); + } + + public synchronized static void startChecking(Context context, + ServerEntity serverEntity, + boolean resetChecked, + BypassCensorshipMethod bypassCensorshipMethodMutableLiveData) { + Intent intent = new Intent(context, SniCheckerService.class); + intent.setAction(ACTION_START); + intent.putExtra(RESET_CHECKED_EXTRA, resetChecked); + intent.putExtra(BYPASS_METHOD, bypassCensorshipMethodMutableLiveData.name()); + if (serverEntity != null) { + intent.putExtra(SELECTED_SERVER, serverEntity.getId()); + context.startService(intent); + } else { + Log.e(TAG, "startChecking: no server selected"); + } + } + + public synchronized static void stopChecking(Context context) { + Intent intent = new Intent(context, SniCheckerService.class); + intent.setAction(ACTION_STOP); + context.startService(intent); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Log.d(TAG, "onStartCommand: intent: " + intent); + if (intent != null) { + String intentAction = intent.getAction(); + Log.d(TAG, "onStartCommand: intentAction: " + intentAction); + + if (ACTION_START.equalsIgnoreCase(intentAction)) { + startForegroundWithNotification("Searching the best SNI"); + Log.d(TAG, "Searching the best SNI"); + + // read params from intent + bypassCensorshipMethod = BypassCensorshipMethod.valueOf(intent.getStringExtra(BYPASS_METHOD)); + boolean resetChecked = intent.getBooleanExtra(RESET_CHECKED_EXTRA, false); + int serverId = intent.getIntExtra(SELECTED_SERVER, Constants.SELECTED_SERVER_ID_AUTO); + + serviceState.postValue(SniCheckerServiceState.ACTIVE); + + executorService.submit(() -> { + ServerEntity serverEntity = appDatabase.serverDAO().getById(serverId); + if (serverEntity != null) { + SniChecker sniChecker = new SniChecker(serverEntity, bypassCensorshipMethod); + selectedServer.postValue(serverEntity); + + acquirePowerLock(); + + foundedSni = null; + + int currentNum = 0; + int allUncheckedCount = appDatabase.sniDAO().countUnchecked(); + if (allUncheckedCount == 0 || resetChecked) { + Log.d(TAG, "Reset all SNI"); + appDatabase.sniDAO().resetAll(); + allUncheckedCount = appDatabase.sniDAO().countUnchecked(); + } + + List sniEntitiesToCheck = appDatabase.sniDAO().getUnchecked(SNI_BATCH_SIZE); + while (serviceState.getValue() == SniCheckerServiceState.ACTIVE + && !sniEntitiesToCheck.isEmpty() + && foundedSni == null) { + + List checkedSniEntities = new ArrayList<>(); + + Iterator iterator = sniEntitiesToCheck.iterator(); + while (serviceState.getValue() == SniCheckerServiceState.ACTIVE + && iterator.hasNext() + && foundedSni == null) { + SniEntity currentSniEntity = iterator.next(); + currentNum++; + + // mark current as checked + currentSniEntity.setChecked(true); + checkedSniEntities.add(currentSniEntity); + + String currentSni = currentSniEntity.getSni(); + currentSniInfo.postValue(currentSni); + + currentProgress.postValue(new Pair<>(currentNum, allUncheckedCount)); + + updateNotificationWithProgress(currentSni, currentNum, allUncheckedCount); + + // checking current + boolean valid = sniChecker.checkSni(currentSni); + if (valid) { + Log.d(TAG, "Founded valid SNI: " + currentSni); + foundedSni = currentSni; + } + } + + if (!checkedSniEntities.isEmpty()) { + // save progress sni + try { + Log.d(TAG, "Saving checked to DB"); + appDatabase.sniDAO().insertAll(checkedSniEntities).get(); + } catch (ExecutionException | InterruptedException e) { + Log.e(TAG, "Error occurs on saved checked!", e); + } + } + + // get next batch + sniEntitiesToCheck = appDatabase.sniDAO().getUnchecked(SNI_BATCH_SIZE); + } + } else { + Log.e(TAG, "Server not found with id: " + serverId); + } + stopCheckingProcess(); + }); + + } else if (ACTION_STOP.equalsIgnoreCase(intentAction)) { + stopCheckingProcess(); + } + } + + // if it stops - it stops + return START_NOT_STICKY; + } + + private void stopCheckingProcess() { + // Release wakelock + releasePowerLock(); + + serviceState.postValue(SniCheckerServiceState.INACTIVE); + + stopForeground(STOP_FOREGROUND_REMOVE); + + if (foundedSni != null) { + showResultNotification("Found SNI", "Found working SNI:" + foundedSni); + + //todo: does need add save action to notification? + SharedPrefUtils.saveSniHostname(this, foundedSni); + } else { + showResultNotification("SNI not found!", "Not found working SNI for server: " + + Optional.ofNullable(selectedServer.getValue()).map(ServerEntity::getServerInfo).orElse("")); + } + } + + private synchronized void acquirePowerLock() { + // release previous power lock + releasePowerLock(); + // we need this lock so our service gets not affected by Doze Mode + PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); + try { + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, SNI_CHECKER_POWER_LOCK); + wakeLock.acquire(5000); + } catch (Exception e) { + Log.e(TAG, "Can't acquire power lock!", e); + } + } + + private synchronized void releasePowerLock() { + if (wakeLock != null && wakeLock.isHeld()) { + try { + wakeLock.release(); + } catch (Exception e) { + Log.e(TAG, "Can't release power lock!", e); + } + } + } + + private void startForegroundWithNotification(String title) { + Notification notification = createNotificationInProgress(title, ""); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(Constants.SNI_CHECKER_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED); + } else { + startForeground(Constants.SNI_CHECKER_NOTIFICATION_ID, notification); + } + } + + private Notification createNotificationInProgress(String title, String message) { + return createNotification(title, message).build(); + } + + private void updateNotificationWithProgress(String sni, int progress, int max) { + NotificationManager notificationManager = (NotificationManager) getSystemService( + NOTIFICATION_SERVICE); + Notification.Builder builder = createNotification("Checking SNI: " + sni, "Checking " + progress + "/" + max); + builder.setProgress(max, progress, false); + + Notification notification = builder.build(); + notificationManager.notify(Constants.SNI_CHECKER_NOTIFICATION_ID, notification); + } + + private Notification.Builder createNotification(String title, String message) { + // In Api level 24 an above, there is no icon in design!!! + Notification.Action actionStopChecking = new Notification.Action.Builder(null, getString(R.string.stop_sni_checking_button_label), stopPendingIntent) + .build(); + Notification.Builder builder = new Notification.Builder(this, Constants.SNI_CHECKER_NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_sql_server_24) + .setContentTitle(title) + .setContentText(message) + .setVisibility(Notification.VISIBILITY_PUBLIC) // Show this notification in its entirety on all lockscreens and while screen sharing. + .setOnlyAlertOnce(true) // so when data is updated don't make sound and alert in android 8.0+ + .setAutoCancel(false) // for not remove notification after press it + .setOngoing(true) // user can't close notification (works only when screen locked) + .addAction(actionStopChecking) + .setContentIntent(launchActivityPendingIntent); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE); // foreground service notification behavior + } + return builder; + } + + private void showResultNotification(String title, String message) { +/* Notification.Action actionSaveFounded = new Notification.Action.Builder(null, getString(R.string.reconnect_action), reconnectPendingIntent) + .build(); + Notification.Action actionContinueChecking = new Notification.Action.Builder(null, getString(R.string.reconnect_action), reconnectPendingIntent) + .build();*/ + Notification notification = new Notification.Builder(this, Constants.SNI_CHECKER_NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_logo) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setContentTitle(title) + .setContentText(message) + .setAutoCancel(true) // if you tap on notification - opens activity and notification dismissed + .setContentIntent(launchActivityPendingIntent) + //.addActions(actionSaveFounded, actionContinueChecking) + .setActions() + .build(); + + NotificationManager notificationManager = (NotificationManager) getSystemService( + NOTIFICATION_SERVICE); + notificationManager.notify(Constants.SNI_CHECKER_NOTIFICATION_ID, notification); + } + +} diff --git a/app/src/main/java/org/fptn/vpn/services/snichecker/SniCheckerServiceState.java b/app/src/main/java/org/fptn/vpn/services/snichecker/SniCheckerServiceState.java new file mode 100644 index 00000000..26f691d8 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/services/snichecker/SniCheckerServiceState.java @@ -0,0 +1,6 @@ +package org.fptn.vpn.services.snichecker; + +public enum SniCheckerServiceState { + ACTIVE, + INACTIVE +} diff --git a/app/src/main/java/org/fptn/vpn/services/vpn/FptnService.java b/app/src/main/java/org/fptn/vpn/services/vpn/FptnService.java index f41d0fef..d476a197 100644 --- a/app/src/main/java/org/fptn/vpn/services/vpn/FptnService.java +++ b/app/src/main/java/org/fptn/vpn/services/vpn/FptnService.java @@ -41,9 +41,9 @@ import org.fptn.vpn.utils.NetworkUtils; import org.fptn.vpn.utils.NotificationUtils; import org.fptn.vpn.utils.SharedPrefUtils; -import org.fptn.vpn.views.home.HomeActivity; import org.fptn.vpn.views.perappvpn.AppInfo; import org.fptn.vpn.services.speedtest.SpeedTestUtils; +import org.fptn.vpn.views.splash.SplashActivity; import org.fptn.vpn.vpnclient.exception.ErrorCode; import org.fptn.vpn.vpnclient.exception.PVNClientException; @@ -174,9 +174,12 @@ public synchronized static void startToDisconnect(Context context) { public void onCreate() { Log.i(TAG, "FptnService.onCreate() Thread.Id: " + Thread.currentThread().getId()); + // Configure notification channels + NotificationUtils.configureNotificationChannel(this); + // pending intent for open MainActivity on tap launchMainActivityPendingIntent = PendingIntent.getActivity(this, 0, - new Intent(this, HomeActivity.class), + new Intent(this, SplashActivity.class), PendingIntent.FLAG_IMMUTABLE); // pending intent for disconnect button in connected notification @@ -545,7 +548,6 @@ private void removeForegroundNotification() { } private void startForegroundWithNotification(String title) { - NotificationUtils.configureNotificationChannel(this); Notification notification = createNotification(title, ""); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { startForeground(Constants.MAIN_CONNECTED_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED); diff --git a/app/src/main/java/org/fptn/vpn/utils/NotificationUtils.java b/app/src/main/java/org/fptn/vpn/utils/NotificationUtils.java index a8aba473..c7add80b 100644 --- a/app/src/main/java/org/fptn/vpn/utils/NotificationUtils.java +++ b/app/src/main/java/org/fptn/vpn/utils/NotificationUtils.java @@ -7,57 +7,76 @@ import org.fptn.vpn.R; import org.fptn.vpn.core.common.Constants; +import org.jetbrains.annotations.NotNull; public class NotificationUtils { public static void configureNotificationChannel(Context context) { NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); // add main notification channel - NotificationChannel mainNotificationChannel = notificationManager.getNotificationChannel(Constants.MAIN_NOTIFICATION_CHANNEL_ID); - int mainNotificationChannelOnDevice = SharedPrefUtils.getNotificationChannelVersion(context, Constants.MAIN_NOTIFICATION_CHANNEL_VERSION); - // remove existed notification channel if their version lower than in constants - if (mainNotificationChannel != null && mainNotificationChannelOnDevice < Constants.MAIN_NOTIFICATION_CHANNEL_VERSION_NUM) { - notificationManager.deleteNotificationChannel(Constants.MAIN_NOTIFICATION_CHANNEL_ID); - mainNotificationChannel = null; - } - - if (mainNotificationChannel == null) { - notificationManager.createNotificationChannelGroup( - new NotificationChannelGroup(Constants.MAIN_NOTIFICATION_CHANNEL_GROUP_ID, context.getString(R.string.notification_group_name))); + createOrUpdateNotificationChannel(context, notificationManager, + Constants.MAIN_NOTIFICATION_CHANNEL_ID, + context.getString(R.string.notification_channel_name), + Constants.MAIN_NOTIFICATION_CHANNEL_VERSION, + Constants.MAIN_NOTIFICATION_CHANNEL_VERSION_NUM, + Constants.MAIN_NOTIFICATION_CHANNEL_GROUP_ID, + context.getString(R.string.notification_group_name), + NotificationManager.IMPORTANCE_LOW, true); - NotificationChannel newNotificationChannel = new NotificationChannel( - Constants.MAIN_NOTIFICATION_CHANNEL_ID, - context.getString(R.string.notification_channel_name), - NotificationManager.IMPORTANCE_LOW); - newNotificationChannel.setGroup(Constants.MAIN_NOTIFICATION_CHANNEL_GROUP_ID); - newNotificationChannel.setSound(null, null); //disable sound + // create error notification channel + createOrUpdateNotificationChannel(context, notificationManager, + Constants.ERROR_NOTIFICATION_CHANNEL_ID, + "When errors", // todo: move to strings + Constants.ERROR_NOTIFICATION_CHANNEL_VERSION, + Constants.ERROR_NOTIFICATION_CHANNEL_VERSION_NUM, + Constants.ERROR_NOTIFICATION_CHANNEL_GROUP_ID, + context.getString(R.string.errors_notification_group_name), + NotificationManager.IMPORTANCE_LOW, true); - notificationManager.createNotificationChannel(newNotificationChannel); - SharedPrefUtils.saveNotificationChannelVersion(context, Constants.MAIN_NOTIFICATION_CHANNEL_VERSION, Constants.MAIN_NOTIFICATION_CHANNEL_VERSION_NUM); - } + // add sni checker notification channel + createOrUpdateNotificationChannel(context, notificationManager, + Constants.SNI_CHECKER_NOTIFICATION_CHANNEL_ID, + "Sni checker", // todo: move to strings + Constants.SNI_CHECKER_NOTIFICATION_CHANNEL_VERSION, + Constants.SNI_CHECKER_NOTIFICATION_CHANNEL_VERSION_NUM, + Constants.SNI_CHECKER_NOTIFICATION_CHANNEL_GROUP_ID, + context.getString(R.string.sni_checker_notification_group_name), + NotificationManager.IMPORTANCE_LOW, true); + } - // add error notification channel - NotificationChannel errorNotificationChannel = notificationManager.getNotificationChannel(Constants.ERROR_NOTIFICATION_CHANNEL_ID); - int errorNotificationChannelOnDevice = SharedPrefUtils.getNotificationChannelVersion(context, Constants.ERROR_NOTIFICATION_CHANNEL_VERSION); + private static void createOrUpdateNotificationChannel(Context context, NotificationManager notificationManager, + @NotNull String channelId, + @NotNull String channelName, + @NotNull String versionTag, + int versionNum, + @NotNull String channelGroupId, + String channelGroupName, + int importance, + boolean disableSound) { + // add notification channel + NotificationChannel notificationChannel = notificationManager.getNotificationChannel(channelId); + int notificationChannelOnDevice = SharedPrefUtils.getNotificationChannelVersion(context, versionTag); // remove existed notification channel if their version lower than in constants - if (errorNotificationChannel != null && errorNotificationChannelOnDevice < Constants.ERROR_NOTIFICATION_CHANNEL_VERSION_NUM) { - notificationManager.deleteNotificationChannel(Constants.ERROR_NOTIFICATION_CHANNEL_ID); - errorNotificationChannel = null; + if (notificationChannel != null && notificationChannelOnDevice < versionNum) { + notificationManager.deleteNotificationChannel(channelId); + notificationChannel = null; } - if (errorNotificationChannel == null) { + if (notificationChannel == null) { notificationManager.createNotificationChannelGroup( - new NotificationChannelGroup(Constants.ERROR_NOTIFICATION_CHANNEL_GROUP_ID, context.getString(R.string.errors_notification_group_name))); + new NotificationChannelGroup(channelGroupId, channelGroupName)); NotificationChannel newNotificationChannel = new NotificationChannel( - Constants.ERROR_NOTIFICATION_CHANNEL_ID, - context.getString(R.string.errors_notification_group_name), - NotificationManager.IMPORTANCE_LOW); - newNotificationChannel.setSound(null, null); //disable sound - newNotificationChannel.setGroup(Constants.ERROR_NOTIFICATION_CHANNEL_GROUP_ID); + channelId, + channelName, + importance); + if (disableSound) { + newNotificationChannel.setSound(null, null); //disable sound + } + newNotificationChannel.setGroup(channelGroupId); notificationManager.createNotificationChannel(newNotificationChannel); - SharedPrefUtils.saveNotificationChannelVersion(context, Constants.ERROR_NOTIFICATION_CHANNEL_VERSION, Constants.ERROR_NOTIFICATION_CHANNEL_VERSION_NUM); + SharedPrefUtils.saveNotificationChannelVersion(context, versionTag, versionNum); } } } diff --git a/app/src/main/java/org/fptn/vpn/views/bypassmethod/BypassMethodsActivity.java b/app/src/main/java/org/fptn/vpn/views/bypassmethod/BypassMethodsActivity.java index ed40d512..2477eed4 100644 --- a/app/src/main/java/org/fptn/vpn/views/bypassmethod/BypassMethodsActivity.java +++ b/app/src/main/java/org/fptn/vpn/views/bypassmethod/BypassMethodsActivity.java @@ -1,35 +1,98 @@ package org.fptn.vpn.views.bypassmethod; import android.annotation.SuppressLint; +import android.app.Activity; import android.app.AlertDialog; +import android.content.ActivityNotFoundException; +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.net.Uri; import android.os.Bundle; +import android.os.IBinder; import android.util.Log; +import android.view.LayoutInflater; import android.view.View; import android.widget.Button; +import android.widget.CheckBox; +import android.widget.ProgressBar; import android.widget.RadioButton; import android.widget.RadioGroup; +import android.widget.Spinner; import android.widget.TextView; +import android.widget.Toast; +import android.widget.ToggleButton; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.lifecycle.ViewModelProvider; import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.textfield.TextInputEditText; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; import org.fptn.vpn.R; +import org.fptn.vpn.database.entity.ServerEntity; import org.fptn.vpn.enums.BypassCensorshipMethod; +import org.fptn.vpn.services.snichecker.SniCheckerService; +import org.fptn.vpn.services.snichecker.SniCheckerServiceState; import org.fptn.vpn.utils.ViewUtils; import org.fptn.vpn.views.CustomBottomNavigationListener; +import org.fptn.vpn.views.adapter.ServerEntityAdapter; +import org.fptn.vpn.vpnclient.exception.PVNClientException; +import java.util.List; import java.util.Optional; public class BypassMethodsActivity extends AppCompatActivity { private final String TAG = this.getClass().getSimpleName(); + private BypassMethodsViewModel viewModel; + private ActivityResultLauncher filePickerLauncher; + private AlertDialog autoSelectDialog; - private View sniLayout; + private ServiceConnection connection; - private BypassMethodsViewModel viewModel; + @Override + protected void onStart() { + super.onStart(); + + connection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + Log.i(TAG, "onServiceConnected: " + name); + SniCheckerService.LocalBinder localBinder = (SniCheckerService.LocalBinder) service; + viewModel.subscribeService(localBinder.getService()); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + try { + if (viewModel != null) { + viewModel.unsubscribe(); + } + } catch (Exception e) { + Log.e(TAG, "Error in onServiceDisconnected: " + e.getMessage()); + } + } + }; + SniCheckerService.bindService(this, connection); + } + + @Override + protected void onStop() { + super.onStop(); + + try { + if (connection != null) { + unbindService(connection); + } + } catch (Exception e) { + Log.e(TAG, "Error unbinding service: " + e.getMessage()); + } + } @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -62,6 +125,8 @@ private void initializeVariable() { } }); + View sniLayout = findViewById(R.id.sni_layout); + RadioButton sniSpoofingRadioButton = findViewById(R.id.sni_spoofing_radio_button); RadioButton obfuscationRadioButton = findViewById(R.id.obfuscation_radio_button); RadioButton sniRealityRadioButton = findViewById(R.id.sni_reality_radio_button); @@ -82,20 +147,19 @@ private void initializeVariable() { } }); - sniLayout = findViewById(R.id.sni_layout); - sniLayout.setOnClickListener(this::onEditSNIServer); // SNI field TextView sniTextField = findViewById(R.id.SNI_text_field); viewModel.getSniMutableLiveData().observe(this, sniTextField::setText); - View editSniButton = findViewById(R.id.imageView); - editSniButton.setOnClickListener(this::onEditSNIServer); + View editSniLayout = findViewById(R.id.edit_sni_layout); + editSniLayout.setOnClickListener(this::onEditSNIServer); // Save and Cancel buttons Button cancelButton = findViewById(R.id.cancel_button); cancelButton.setOnClickListener(v -> { Log.d(TAG, "Cancel button clicked"); + finish(); }); @@ -107,6 +171,189 @@ private void initializeVariable() { finish(); }); + + // SNI Auto + TextView sniCountLabel = findViewById(R.id.loaded_sni_count_label); + + Button loadSniButton = findViewById(R.id.load_sni_button); + loadSniButton.setOnClickListener(view -> onLoadButtonClicked()); + + Button deleteSniButton = findViewById(R.id.delete_sni_button); + deleteSniButton.setOnClickListener(view -> onDeleteButtonClicked()); + + ToggleButton startStopCheckingSniButton = findViewById(R.id.auto_select_sni_button); + startStopCheckingSniButton.setOnClickListener(v -> onAutoSelectSniClicked()); + + ProgressBar sniProgressBar = findViewById(R.id.sni_checking_progress_bar); + TextView sniProgressBarLabel = findViewById(R.id.sni_checking_progress_bar_label); + viewModel.getCurrentProgress().observe(this, progressPair -> { + if (progressPair != null) { + Integer max = progressPair.second; + sniProgressBar.setMax(max); + Integer progress = progressPair.first; + sniProgressBar.setProgress(progress); + + sniProgressBarLabel.setText(String.format("%d/%d", progress, max)); + } + }); + + TextView currentCheckingSni = findViewById(R.id.current_sni_text); + viewModel.getCurrentCheckingSniInfo().observe(this, currentCheckingSni::setText); + + TextView checkingServerTextView = findViewById(R.id.selected_server_text); + viewModel.getSelectedServer().observe(this, server -> checkingServerTextView.setText(server.getServerInfo())); + + View cancelSaveButtonsView = findViewById(R.id.buttons_layout); + View checkingInProgressView = findViewById(R.id.checking_in_progress_view); + View loadDeleteButtonGroup = findViewById(R.id.load_delete_button_group); + + // todo: disable navigation bar when sni checking active + //View settingsMenuItem = findViewById(R.id.menuSettings); + viewModel.getServiceState().observe(this, serviceState -> { + if (serviceState == SniCheckerServiceState.ACTIVE) { + ViewUtils.hideView(loadDeleteButtonGroup); + ViewUtils.hideView(cancelSaveButtonsView); + + ViewUtils.showView(checkingInProgressView); + + startStopCheckingSniButton.setChecked(true); + //settingsMenuItem.setEnabled(false); + } else { + ViewUtils.showView(loadDeleteButtonGroup); + ViewUtils.showView(cancelSaveButtonsView); + + ViewUtils.hideView(checkingInProgressView); + + startStopCheckingSniButton.setChecked(false); + //settingsMenuItem.setEnabled(true); + + // todo: this is for what? + viewModel.refreshCurrentSni(); + } + }); + + viewModel.getSniCountLiveData().observe(this, + count -> { + sniCountLabel.setText(String.valueOf(count)); + + boolean isEnabled = count > 0; + deleteSniButton.setEnabled(isEnabled); + startStopCheckingSniButton.setEnabled(isEnabled); + } + ); + + // Register the activity result launcher + // This must be done in onCreate or as a class member initializer. + filePickerLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == Activity.RESULT_OK) { + Intent data = result.getData(); + if (data != null && data.getData() != null) { + Uri uri = data.getData(); + Log.d(TAG, "File selected: " + uri.getPath()); + try { + viewModel.readFileContent(uri); + } catch (PVNClientException e) { + Toast.makeText(BypassMethodsActivity.this, e.errorMessage, Toast.LENGTH_SHORT).show(); + } + } + } else { + Log.d(TAG, "File selection cancelled."); + } + }); + } + + private void onAutoSelectSniClicked() { + if (viewModel.getServiceState().getValue() == SniCheckerServiceState.INACTIVE) { + Futures.addCallback(viewModel.getAllServers(), new FutureCallback<>() { + @Override + public void onSuccess(List servers) { + showAutoSelectDialog(servers); + } + + @Override + public void onFailure(Throwable t) { + Log.e(TAG, "Fail to load servers.", t); + } + }, getMainExecutor()); + } else { + SniCheckerService.stopChecking(this); + } + } + + private void showAutoSelectDialog(List serverEntities) { + // Prevent creating multiple dialogs + if (autoSelectDialog != null && autoSelectDialog.isShowing()) { + return; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + // Inflate the custom layout + LayoutInflater inflater = this.getLayoutInflater(); + View dialogView = inflater.inflate(R.layout.dialog_autoselect_sni, null); + builder.setView(dialogView); + + // --- Setup Spinner --- + ServerEntityAdapter serverEntityAdapter = new ServerEntityAdapter(serverEntities, R.layout.home_list_recycler_server_item); + + Spinner serverSpinner = dialogView.findViewById(R.id.dialog_server_spinner); + serverSpinner.setAdapter(serverEntityAdapter); + + CheckBox resetCheckedCheckbox = dialogView.findViewById(R.id.reset_checked_checkbox); + + // --- Setup Buttons --- + Button buttonCancel = dialogView.findViewById(R.id.dialog_button_cancel); + Button buttonStart = dialogView.findViewById(R.id.dialog_button_start); + + // Create the dialog before setting click listeners to allow for dismissing it + autoSelectDialog = builder.create(); + + buttonCancel.setOnClickListener(v -> { + Log.d(TAG, "Auto-select dialog cancelled."); + + autoSelectDialog.dismiss(); + }); + + buttonStart.setOnClickListener(v -> { + // Get the originally selected server object + int selectedPosition = serverSpinner.getSelectedItemPosition(); + ServerEntity selectedServer = serverEntities.get(selectedPosition); + + Log.d(TAG, "Starting SNI auto-select for server: " + selectedServer.getServerInfo()); + SniCheckerService.startChecking(this, selectedServer, resetCheckedCheckbox.isChecked(), + viewModel.getBypassCensorshipMethodMutableLiveData().getValue()); + + autoSelectDialog.dismiss(); + }); + + autoSelectDialog.setOnCancelListener(dialog -> { + ToggleButton startStopCheckingSniButton = findViewById(R.id.auto_select_sni_button); + startStopCheckingSniButton.setChecked(false); + }); + + autoSelectDialog.show(); + } + + private void onDeleteButtonClicked() { + viewModel.deleteAllSni(); + } + + private void onLoadButtonClicked() { + // Create an intent to open the file picker + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + // We are looking for any kind of file, but you could restrict it, + // for example, to "text/plain" for text files. + intent.setType("text/plain"); + intent.addCategory(Intent.CATEGORY_OPENABLE); + + try { + // Launch the intent using the ActivityResultLauncher + filePickerLauncher.launch(Intent.createChooser(intent, "Select a SNI file")); + } catch (ActivityNotFoundException ex) { + // Potentially handle the case where the device has no file manager + Toast.makeText(this, "Please install a File Manager.", Toast.LENGTH_SHORT).show(); + } } public void onEditSNIServer(View view) { diff --git a/app/src/main/java/org/fptn/vpn/views/bypassmethod/BypassMethodsViewModel.java b/app/src/main/java/org/fptn/vpn/views/bypassmethod/BypassMethodsViewModel.java index 270eb15e..27a1704a 100644 --- a/app/src/main/java/org/fptn/vpn/views/bypassmethod/BypassMethodsViewModel.java +++ b/app/src/main/java/org/fptn/vpn/views/bypassmethod/BypassMethodsViewModel.java @@ -1,36 +1,80 @@ package org.fptn.vpn.views.bypassmethod; import android.app.Application; +import android.net.Uri; +import android.util.Log; +import android.util.Pair; import androidx.annotation.NonNull; import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.MutableLiveData; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; + import org.fptn.vpn.R; +import org.fptn.vpn.database.AppDatabase; +import org.fptn.vpn.database.entity.ServerEntity; +import org.fptn.vpn.database.entity.SniEntity; import org.fptn.vpn.enums.BypassCensorshipMethod; +import org.fptn.vpn.services.snichecker.SniCheckerService; +import org.fptn.vpn.services.snichecker.SniCheckerServiceState; import org.fptn.vpn.utils.SharedPrefUtils; +import org.fptn.vpn.vpnclient.exception.PVNClientException; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import lombok.Getter; public class BypassMethodsViewModel extends AndroidViewModel { + private final String TAG = this.getClass().getSimpleName(); @Getter private final MutableLiveData sniMutableLiveData; - @Getter private final MutableLiveData bypassCensorshipMethodMutableLiveData; + @Getter + private final MutableLiveData sniCountLiveData = new MutableLiveData<>(0); + + @Getter + private final MutableLiveData serviceState = new MutableLiveData<>(SniCheckerServiceState.INACTIVE); + + @Getter + private final MutableLiveData currentCheckingSniInfo = new MutableLiveData<>(""); + + @Getter + private final MutableLiveData> currentProgress = new MutableLiveData<>(Pair.create(0, 1)); + + @Getter + private final MutableLiveData selectedServer = new MutableLiveData<>(ServerEntity.AUTO); + + private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); + private final ExecutorService executorService = Executors.newSingleThreadExecutor(); public BypassMethodsViewModel(@NonNull Application application) { super(application); sniMutableLiveData = new MutableLiveData<>(SharedPrefUtils.getSniHostname(application)); bypassCensorshipMethodMutableLiveData = new MutableLiveData<>(SharedPrefUtils.getBypassCensorshipMethod(application)); + + refreshSniCount(); } public String getCurrentSni() { return sniMutableLiveData.getValue(); } + public void refreshCurrentSni() { + sniMutableLiveData.postValue(SharedPrefUtils.getSniHostname(getApplication())); + } + public void setNewSni(String sni) { sniMutableLiveData.postValue(sni); SharedPrefUtils.saveSniHostname(getApplication(), sni); @@ -65,4 +109,81 @@ public void validateAndSetSni(String newSni) { setNewSni(newSni); } } + + public void refreshSniCount() { + executorService.submit(() -> { + int sniCount = appDatabase.sniDAO().count(); + sniCountLiveData.postValue(sniCount); + }); + } + + public ListenableFuture> getAllServers() { + return appDatabase.serverDAO().getServerListAsync(false); + } + + public void deleteAllSni() { + executorService.submit(() -> { + appDatabase.sniDAO().deleteAll(); + int sniCount = appDatabase.sniDAO().count(); + sniCountLiveData.postValue(sniCount); + }); + } + + public void readFileContent(Uri uri) throws PVNClientException { + List sniList = new ArrayList<>(); + try (InputStream inputStream = getApplication().getContentResolver().openInputStream(uri); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + reader.lines().forEach(line -> { + // Trim whitespace and ignore empty or commented lines + String trimmedLine = line.trim(); + if (!trimmedLine.isEmpty() && !trimmedLine.startsWith("#")) { + sniList.add(SniEntity.builder() + .sni(trimmedLine) + .checked(false) + .build()); + } + } + ); + } catch (Exception e) { + Log.e(TAG, "Error reading SNI file", e); + throw new PVNClientException("Error: Could not read the file."); + } + + if (!sniList.isEmpty()) { + ListenableFuture future = appDatabase.sniDAO().insertAll(sniList); + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(Void result) { + Log.d(TAG, "Successfully inserted " + sniList.size() + " SNIs into the database."); + refreshSniCount(); + } + + @Override + public void onFailure(Throwable t) { + Log.e(TAG, "DB error occurs!", t); + } + }, executorService); + + } else { + Log.d(TAG, "No valid SNIs found in the selected file."); + throw new PVNClientException("File is empty or contains no valid SNI entries."); + } + } + + public void subscribeService(SniCheckerService service) { + service.getServiceState().observeForever(state -> { + serviceState.postValue(state); + if (state == SniCheckerServiceState.ACTIVE) { + bypassCensorshipMethodMutableLiveData.postValue(service.getBypassCensorshipMethod()); + } + }); + service.getSelectedServer().observeForever(selectedServer::postValue); + service.getCurrentSniInfo().observeForever(currentCheckingSniInfo::postValue); + service.getCurrentProgress().observeForever(currentProgress::postValue); + } + + public void unsubscribe() { + // todo: check memory leaks and maybe remove observers + } + } diff --git a/app/src/main/java/org/fptn/vpn/views/home/HomeActivity.java b/app/src/main/java/org/fptn/vpn/views/home/HomeActivity.java index e66fec4a..0c9f3d8e 100644 --- a/app/src/main/java/org/fptn/vpn/views/home/HomeActivity.java +++ b/app/src/main/java/org/fptn/vpn/views/home/HomeActivity.java @@ -34,6 +34,7 @@ import org.fptn.vpn.utils.CustomSpinner; import org.fptn.vpn.utils.PermissionsUtils; import org.fptn.vpn.utils.SharedPrefUtils; +import org.fptn.vpn.utils.ViewUtils; import org.fptn.vpn.views.CustomBottomNavigationListener; import org.fptn.vpn.views.adapter.ServerEntityAdapter; import org.fptn.vpn.services.vpn.FptnService; @@ -230,37 +231,25 @@ protected void onResume() { } private void disconnectedStateUiItems() { - hideView(connectionTimeFrame); - hideView(serverInfoFrame); - hideView(homeSpeedFrame); - hideView(permissionWarningFrame); + ViewUtils.hideView(connectionTimeFrame); + ViewUtils.hideView(serverInfoFrame); + ViewUtils.hideView(homeSpeedFrame); + ViewUtils.hideView(permissionWarningFrame); - showView(spinnerServers); + ViewUtils.showView(spinnerServers); } private void connectedStateUiItems() { - showView(connectionTimeFrame); - showView(serverInfoFrame); - showView(homeSpeedFrame); + ViewUtils.showView(connectionTimeFrame); + ViewUtils.showView(serverInfoFrame); + ViewUtils.showView(homeSpeedFrame); // check is need to show permissions warning if (!PermissionsUtils.isAllOptionalPermissionsGranted(this)) { - showView(permissionWarningFrame); + ViewUtils.showView(permissionWarningFrame); } - hideView(spinnerServers); - } - - private void hideView(View view) { - if (view != null) { - view.setVisibility(View.GONE); - } - } - - private void showView(View view) { - if (view != null) { - view.setVisibility(View.VISIBLE); - } + ViewUtils.hideView(spinnerServers); } public void onClickToStartStop(View v) { diff --git a/app/src/main/java/org/fptn/vpn/views/splash/SplashActivity.java b/app/src/main/java/org/fptn/vpn/views/splash/SplashActivity.java index e37b94ea..c1f1a0c9 100644 --- a/app/src/main/java/org/fptn/vpn/views/splash/SplashActivity.java +++ b/app/src/main/java/org/fptn/vpn/views/splash/SplashActivity.java @@ -13,6 +13,9 @@ import org.fptn.vpn.R; import org.fptn.vpn.database.AppDatabase; +import org.fptn.vpn.services.snichecker.SniCheckerService; +import org.fptn.vpn.services.snichecker.SniCheckerServiceState; +import org.fptn.vpn.views.bypassmethod.BypassMethodsActivity; import org.fptn.vpn.views.home.HomeActivity; import org.fptn.vpn.views.login.LoginActivity; @@ -34,7 +37,11 @@ private void initializeVariable() { public void onSuccess(Integer count) { Intent intent; if (count > 0) { - intent = new Intent(SplashActivity.this, HomeActivity.class); + if (SniCheckerService.getStaticServiceState().getValue() == SniCheckerServiceState.ACTIVE) { + intent = new Intent(SplashActivity.this, BypassMethodsActivity.class); + } else { + intent = new Intent(SplashActivity.this, HomeActivity.class); + } } else { intent = new Intent(SplashActivity.this, LoginActivity.class); } diff --git a/app/src/main/proto/protocol.proto b/app/src/main/proto/protocol.proto deleted file mode 100644 index f6ff5f59..00000000 --- a/app/src/main/proto/protocol.proto +++ /dev/null @@ -1,36 +0,0 @@ -syntax = "proto3"; - -package fptn.protocol; -option java_package = "org.fptn.protocol"; - - -enum MessageType { - MSG_ERROR = 0; - MSG_IP_PACKET = 1; -} - -enum ErrorType { - ERROR_DEFAULT = 0; - ERROR_WRONG_VERSION = 1; - ERROR_SESSION_EXPIRED = 2; -} - -message ErrorMessage { - ErrorType error_type = 1; - string error_msg = 2; -} - -message IPPacket { - bytes payload = 1; - bytes padding_data = 2; -} - -message Message { - int32 protocol_version = 1; - MessageType msg_type = 2; - - oneof message_content { - ErrorMessage error = 3; - IPPacket packet = 4; - } -} diff --git a/app/src/main/res/color/auto_select_sni_toggle_text_color.xml b/app/src/main/res/color/auto_select_sni_toggle_text_color.xml new file mode 100644 index 00000000..6b40877d --- /dev/null +++ b/app/src/main/res/color/auto_select_sni_toggle_text_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/auto_select_sni_toggle_back.xml b/app/src/main/res/drawable/auto_select_sni_toggle_back.xml new file mode 100644 index 00000000..1eca78e4 --- /dev/null +++ b/app/src/main/res/drawable/auto_select_sni_toggle_back.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_sql_server_24.xml b/app/src/main/res/drawable/ic_sql_server_24.xml new file mode 100644 index 00000000..c2431322 --- /dev/null +++ b/app/src/main/res/drawable/ic_sql_server_24.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/server_icon.xml b/app/src/main/res/drawable/server_icon.xml new file mode 100644 index 00000000..576ea043 --- /dev/null +++ b/app/src/main/res/drawable/server_icon.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/settings_button_background.xml b/app/src/main/res/drawable/settings_button_background.xml new file mode 100644 index 00000000..c6afe7c3 --- /dev/null +++ b/app/src/main/res/drawable/settings_button_background.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_autoselect_sni.xml b/app/src/main/res/layout/dialog_autoselect_sni.xml new file mode 100644 index 00000000..1f7b5df1 --- /dev/null +++ b/app/src/main/res/layout/dialog_autoselect_sni.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + +