From 67a4c352518628a5e780936159ca4becf8f9e82c Mon Sep 17 00:00:00 2001 From: Stas Skokov <7090stas@gmail.com> Date: Thu, 11 Jun 2026 17:31:29 +1000 Subject: [PATCH 1/3] [bugfix] server selection --- app/src/main/java/org/fptn/vpn/views/home/HomeActivity.java | 1 + 1 file changed, 1 insertion(+) 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 f545cd7d..77479710 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 @@ -148,6 +148,7 @@ private void initializeVariable() { ((ServerEntityAdapter) spinnerServers.getAdapter()).setServerEntityList(serverEntities); spinnerServers.performClosedEvent(); // FIX SPINNER BACKGROUND + updateSpinnerSelection(); }); View settingsMenuItem = findViewById(R.id.menuSettings); From 754de033ba6a6f8b37915321529fdaf47540ca7f Mon Sep 17 00:00:00 2001 From: Stas Skokov <7090stas@gmail.com> Date: Thu, 11 Jun 2026 21:38:37 +1000 Subject: [PATCH 2/3] jetpack compose --- app/build.gradle.kts | 13 + .../snichecker/SniCheckerService.java | 5 +- .../fptn/vpn/ui/components/BottomNavBar.kt | 125 ++++ .../org/fptn/vpn/ui/components/HtmlText.kt | 33 ++ .../vpn/ui/screens/BypassMethodsScreen.kt | 534 ++++++++++++++++++ .../ui/screens/ExperimentalSettingsScreen.kt | 270 +++++++++ .../org/fptn/vpn/ui/screens/HomeScreen.kt | 325 +++++++++++ .../org/fptn/vpn/ui/screens/LoginScreen.kt | 134 +++++ .../org/fptn/vpn/ui/screens/LogsScreen.kt | 61 ++ .../fptn/vpn/ui/screens/PerAppVpnScreen.kt | 308 ++++++++++ .../org/fptn/vpn/ui/screens/SettingsScreen.kt | 304 ++++++++++ .../org/fptn/vpn/ui/screens/SplashScreen.kt | 30 + .../fptn/vpn/ui/screens/UpdateTokenScreen.kt | 168 ++++++ .../java/org/fptn/vpn/ui/theme/FptnTheme.kt | 41 ++ .../bypassmethod/BypassMethodsActivity.java | 476 ---------------- .../bypassmethod/BypassMethodsActivity.kt | 118 ++++ .../bypassmethod/BypassMethodsViewModel.java | 4 + .../ExperimentalSettingsActivity.java | 230 -------- .../ExperimentalSettingsActivity.kt | 97 ++++ .../org/fptn/vpn/views/home/HomeActivity.java | 467 --------------- .../org/fptn/vpn/views/home/HomeActivity.kt | 273 +++++++++ .../org/fptn/vpn/views/log/LogsActivity.java | 197 ------- .../org/fptn/vpn/views/log/LogsActivity.kt | 76 +++ .../fptn/vpn/views/login/LoginActivity.java | 113 ---- .../org/fptn/vpn/views/login/LoginActivity.kt | 62 ++ .../perappvpn/PerAppVpnModeActivity.java | 150 ----- .../views/perappvpn/PerAppVpnModeActivity.kt | 73 +++ .../vpn/views/settings/SettingsActivity.java | 241 -------- .../vpn/views/settings/SettingsActivity.kt | 125 ++++ .../fptn/vpn/views/splash/SplashActivity.java | 61 -- .../fptn/vpn/views/splash/SplashActivity.kt | 53 ++ .../updatetoken/UpdateTokenActivity.java | 117 ---- .../views/updatetoken/UpdateTokenActivity.kt | 70 +++ gradle/libs.versions.toml | 14 + 34 files changed, 3315 insertions(+), 2053 deletions(-) create mode 100644 app/src/main/java/org/fptn/vpn/ui/components/BottomNavBar.kt create mode 100644 app/src/main/java/org/fptn/vpn/ui/components/HtmlText.kt create mode 100644 app/src/main/java/org/fptn/vpn/ui/screens/BypassMethodsScreen.kt create mode 100644 app/src/main/java/org/fptn/vpn/ui/screens/ExperimentalSettingsScreen.kt create mode 100644 app/src/main/java/org/fptn/vpn/ui/screens/HomeScreen.kt create mode 100644 app/src/main/java/org/fptn/vpn/ui/screens/LoginScreen.kt create mode 100644 app/src/main/java/org/fptn/vpn/ui/screens/LogsScreen.kt create mode 100644 app/src/main/java/org/fptn/vpn/ui/screens/PerAppVpnScreen.kt create mode 100644 app/src/main/java/org/fptn/vpn/ui/screens/SettingsScreen.kt create mode 100644 app/src/main/java/org/fptn/vpn/ui/screens/SplashScreen.kt create mode 100644 app/src/main/java/org/fptn/vpn/ui/screens/UpdateTokenScreen.kt create mode 100644 app/src/main/java/org/fptn/vpn/ui/theme/FptnTheme.kt delete mode 100644 app/src/main/java/org/fptn/vpn/views/bypassmethod/BypassMethodsActivity.java create mode 100644 app/src/main/java/org/fptn/vpn/views/bypassmethod/BypassMethodsActivity.kt delete mode 100644 app/src/main/java/org/fptn/vpn/views/experimentalsettings/ExperimentalSettingsActivity.java create mode 100644 app/src/main/java/org/fptn/vpn/views/experimentalsettings/ExperimentalSettingsActivity.kt delete mode 100644 app/src/main/java/org/fptn/vpn/views/home/HomeActivity.java create mode 100644 app/src/main/java/org/fptn/vpn/views/home/HomeActivity.kt delete mode 100644 app/src/main/java/org/fptn/vpn/views/log/LogsActivity.java create mode 100644 app/src/main/java/org/fptn/vpn/views/log/LogsActivity.kt delete mode 100644 app/src/main/java/org/fptn/vpn/views/login/LoginActivity.java create mode 100644 app/src/main/java/org/fptn/vpn/views/login/LoginActivity.kt delete mode 100644 app/src/main/java/org/fptn/vpn/views/perappvpn/PerAppVpnModeActivity.java create mode 100644 app/src/main/java/org/fptn/vpn/views/perappvpn/PerAppVpnModeActivity.kt delete mode 100644 app/src/main/java/org/fptn/vpn/views/settings/SettingsActivity.java create mode 100644 app/src/main/java/org/fptn/vpn/views/settings/SettingsActivity.kt delete mode 100644 app/src/main/java/org/fptn/vpn/views/splash/SplashActivity.java create mode 100644 app/src/main/java/org/fptn/vpn/views/splash/SplashActivity.kt delete mode 100644 app/src/main/java/org/fptn/vpn/views/updatetoken/UpdateTokenActivity.java create mode 100644 app/src/main/java/org/fptn/vpn/views/updatetoken/UpdateTokenActivity.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 68c40c8e..7a1a46f3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,6 +7,8 @@ plugins { id("pvnclient.android.application") id("com.google.gms.google-services") alias(libs.plugins.crashlytics) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.lombok) } val keystorePropertiesFile: File = rootProject.file("keystore.properties") @@ -101,6 +103,7 @@ android { } buildFeatures { viewBinding = true + compose = true } externalNativeBuild { @@ -113,6 +116,16 @@ android { dependencies { implementation(platform(libs.firebase.bom)) + val composeBom = platform(libs.compose.bom) + implementation(composeBom) + implementation(libs.accompanist.drawablepainter) + implementation(libs.activity.compose) + implementation(libs.compose.material3) + implementation(libs.compose.runtime.livedata) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.lifecycle.viewmodel.compose) + debugImplementation(libs.compose.ui.tooling) implementation(project(":core:common")) implementation(project(":vpnclient")) implementation(libs.androidx.activity) 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 index 3207cdf1..eeb8c9e5 100644 --- a/app/src/main/java/org/fptn/vpn/services/snichecker/SniCheckerService.java +++ b/app/src/main/java/org/fptn/vpn/services/snichecker/SniCheckerService.java @@ -52,9 +52,12 @@ public class SniCheckerService extends Service { public static final int SNI_BATCH_SIZE = 25; - @Getter private static final MutableLiveData staticServiceState = new MutableLiveData<>(SniCheckerServiceState.INACTIVE); + public static MutableLiveData getStaticServiceState() { + return staticServiceState; + } + @Getter private final MutableLiveData serviceState = new MutableLiveData<>(SniCheckerServiceState.INACTIVE); diff --git a/app/src/main/java/org/fptn/vpn/ui/components/BottomNavBar.kt b/app/src/main/java/org/fptn/vpn/ui/components/BottomNavBar.kt new file mode 100644 index 00000000..fccedf54 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/ui/components/BottomNavBar.kt @@ -0,0 +1,125 @@ +package org.fptn.vpn.ui.components + +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.text.Html +import android.text.method.LinkMovementMethod +import android.widget.ImageView +import android.widget.TextView +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.google.zxing.BarcodeFormat +import com.google.zxing.MultiFormatWriter +import org.fptn.vpn.R +import org.fptn.vpn.ui.theme.Primary +import org.fptn.vpn.ui.theme.Secondary +import org.fptn.vpn.ui.theme.White +import org.fptn.vpn.ui.theme.White10 + +enum class BottomNavTab { HOME, SETTINGS, SHARE } + +@Composable +fun BottomNavBar( + current: BottomNavTab, + onHome: () -> Unit, + onSettings: () -> Unit, + context: Context, +) { + NavigationBar( + containerColor = Primary, + contentColor = White, + ) { + NavigationBarItem( + selected = current == BottomNavTab.HOME, + onClick = { if (current != BottomNavTab.HOME) onHome() }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_baseline_home_24), + contentDescription = stringResource(R.string.menu_home) + ) + }, + label = { Text(stringResource(R.string.menu_home)) }, + colors = navItemColors(), + ) + NavigationBarItem( + selected = current == BottomNavTab.SETTINGS, + onClick = { if (current != BottomNavTab.SETTINGS) onSettings() }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_baseline_settings_24), + contentDescription = stringResource(R.string.menu_settings) + ) + }, + label = { Text(stringResource(R.string.menu_settings)) }, + colors = navItemColors(), + ) + NavigationBarItem( + selected = false, + onClick = { showShareDialog(context) }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_baseline_share_24), + contentDescription = stringResource(R.string.menu_share) + ) + }, + label = { Text(stringResource(R.string.menu_share)) }, + colors = navItemColors(), + ) + } +} + +@Composable +private fun navItemColors() = NavigationBarItemDefaults.colors( + selectedIconColor = Secondary, + selectedTextColor = Secondary, + unselectedIconColor = White10, + unselectedTextColor = White10, + indicatorColor = Color.Transparent, +) + +private fun showShareDialog(context: Context) { + val qrBitmap = generateQRCode(context.getString(R.string.play_market_link), 500, 500) + val inflater = android.view.LayoutInflater.from(context) + val dialogView = inflater.inflate(R.layout.share_dialog, null) + + (dialogView.findViewById(R.id.qr_code_image)).setImageBitmap(qrBitmap) + val textView = dialogView.findViewById(R.id.link_text) + textView.text = Html.fromHtml(context.getString(R.string.info_message_html), Html.FROM_HTML_MODE_LEGACY) + textView.movementMethod = LinkMovementMethod.getInstance() + + AlertDialog.Builder(context) + .setView(dialogView) + .setMessage(R.string.menu_share) + .setPositiveButton(R.string.share_via_message) { _, _ -> shareViaMessage(context) } + .setNeutralButton(R.string.close_button_text, null) + .create() + .show() +} + +private fun shareViaMessage(context: Context) { + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, context.getString(R.string.share_message)) + } + context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.share_title))) +} + +private fun generateQRCode(text: String, width: Int, height: Int): Bitmap { + val bitMatrix = MultiFormatWriter().encode(text, BarcodeFormat.QR_CODE, width, height) + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + for (x in 0 until width) { + for (y in 0 until height) { + bitmap.setPixel(x, y, if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()) + } + } + return bitmap +} diff --git a/app/src/main/java/org/fptn/vpn/ui/components/HtmlText.kt b/app/src/main/java/org/fptn/vpn/ui/components/HtmlText.kt new file mode 100644 index 00000000..c04c5db0 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/ui/components/HtmlText.kt @@ -0,0 +1,33 @@ +package org.fptn.vpn.ui.components + +import android.text.Html +import android.text.method.LinkMovementMethod +import android.widget.TextView +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.viewinterop.AndroidView + +@Composable +fun HtmlText( + html: String, + modifier: Modifier = Modifier, + textColor: Color = Color.White, + textSizeSp: Float = 16f, +) { + AndroidView( + modifier = modifier, + factory = { context -> + TextView(context).apply { + movementMethod = LinkMovementMethod.getInstance() + setTextColor(textColor.toArgb()) + setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, textSizeSp) + setPadding(8, 8, 8, 8) + } + }, + update = { view -> + view.text = Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY) + } + ) +} diff --git a/app/src/main/java/org/fptn/vpn/ui/screens/BypassMethodsScreen.kt b/app/src/main/java/org/fptn/vpn/ui/screens/BypassMethodsScreen.kt new file mode 100644 index 00000000..ff01f2b6 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/ui/screens/BypassMethodsScreen.kt @@ -0,0 +1,534 @@ +package org.fptn.vpn.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.fptn.vpn.R +import org.fptn.vpn.database.entity.ServerEntity +import org.fptn.vpn.enums.BypassCensorshipMethod +import org.fptn.vpn.enums.SniSpoofingMode +import org.fptn.vpn.services.snichecker.SniCheckerServiceState +import org.fptn.vpn.ui.components.BottomNavBar +import org.fptn.vpn.ui.components.BottomNavTab +import org.fptn.vpn.ui.theme.Primary +import org.fptn.vpn.ui.theme.Secondary +import org.fptn.vpn.ui.theme.White +import org.fptn.vpn.ui.theme.White10 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BypassMethodsScreen( + bypassMethod: BypassCensorshipMethod, + sniSpoofingMode: SniSpoofingMode, + sniText: String, + sniCount: Int, + serviceState: SniCheckerServiceState, + progress: Pair, + currentCheckingSni: String, + selectedServer: ServerEntity, + allServers: List, + onBypassMethodChange: (BypassCensorshipMethod) -> Unit, + onSniSpoofingModeChange: (SniSpoofingMode) -> Unit, + onEditSni: (String) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, + onStartAutoSelect: (ServerEntity, Boolean) -> Unit, + onStopAutoSelect: () -> Unit, + onHome: () -> Unit, + onSettings: () -> Unit, +) { + val context = LocalContext.current + var showEditSniDialog by remember { mutableStateOf(false) } + var showAutoSelectDialog by remember { mutableStateOf(false) } + + if (showEditSniDialog) { + EditSniDialog( + currentSni = sniText, + onSave = { newSni -> + onEditSni(newSni) + showEditSniDialog = false + }, + onReset = { + onEditSni(context.getString(R.string.default_sni)) + showEditSniDialog = false + }, + onDismiss = { showEditSniDialog = false } + ) + } + + if (showAutoSelectDialog) { + AutoSelectDialog( + servers = allServers, + onStart = { server, reset -> + onStartAutoSelect(server, reset) + showAutoSelectDialog = false + }, + onDismiss = { + showAutoSelectDialog = false + } + ) + } + + Scaffold( + containerColor = Primary, + bottomBar = { + BottomNavBar( + current = BottomNavTab.SETTINGS, + onHome = onHome, + onSettings = onSettings, + context = context, + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(Primary) + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.bypass_methods_title), + color = White, + fontSize = 20.sp, + modifier = Modifier.padding(bottom = 8.dp) + ) + Text( + text = stringResource(R.string.bypass_methods_info), + color = White, + fontSize = 14.sp, + modifier = Modifier.padding(bottom = 16.dp) + ) + + RadioMethodRow( + label = stringResource(R.string.sni_reality_radio_button_label), + selected = bypassMethod == BypassCensorshipMethod.SNI_REALITY, + onClick = { onBypassMethodChange(BypassCensorshipMethod.SNI_REALITY) } + ) + RadioMethodRow( + label = stringResource(R.string.tls_handshake_obfuscation_radio_button_label), + selected = bypassMethod == BypassCensorshipMethod.TLS_OBFUSCATION, + onClick = { onBypassMethodChange(BypassCensorshipMethod.TLS_OBFUSCATION) } + ) + + if (bypassMethod == BypassCensorshipMethod.SNI_REALITY) { + Spacer(modifier = Modifier.height(16.dp)) + + SniSpoofingDropdown( + current = sniSpoofingMode, + onSelected = onSniSpoofingModeChange, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .background(White10, RoundedCornerShape(12.dp)) + .clickable { showEditSniDialog = true } + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = stringResource(R.string.your_current_sni), + color = White, + fontSize = 12.sp + ) + Text( + text = sniText, + color = Secondary, + fontSize = 16.sp, + ) + } + Text( + text = stringResource(R.string.save_button), + color = Secondary, + fontSize = 14.sp + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = stringResource(R.string.loaded_sni_label), + color = White, + fontSize = 14.sp + ) + Text( + text = sniCount.toString(), + color = Secondary, + fontSize = 16.sp + ) + } + + Button( + onClick = { + if (serviceState == SniCheckerServiceState.INACTIVE) { + showAutoSelectDialog = true + } else { + onStopAutoSelect() + } + }, + enabled = sniCount > 0 || serviceState == SniCheckerServiceState.ACTIVE, + colors = ButtonDefaults.buttonColors( + containerColor = if (serviceState == SniCheckerServiceState.ACTIVE) Color(0xFFCC0000) else Secondary, + contentColor = Primary, + ), + shape = RoundedCornerShape(100.dp), + ) { + Text( + text = if (serviceState == SniCheckerServiceState.ACTIVE) + stringResource(R.string.stop_sni_checking_button_label) + else + stringResource(R.string.start_sni_checking_button_label) + ) + } + } + + if (serviceState == SniCheckerServiceState.ACTIVE) { + Spacer(modifier = Modifier.height(12.dp)) + LinearProgressIndicator( + progress = { if (progress.second > 0) progress.first.toFloat() / progress.second else 0f }, + modifier = Modifier.fillMaxWidth(), + color = Secondary, + trackColor = White10, + ) + Text( + text = "${progress.first}/${progress.second}", + color = White, + fontSize = 12.sp, + modifier = Modifier.padding(top = 4.dp) + ) + if (currentCheckingSni.isNotEmpty()) { + Text( + text = "${stringResource(R.string.checking_current_sni)} $currentCheckingSni", + color = White, + fontSize = 12.sp + ) + } + Text( + text = stringResource(R.string.selected_server) + ": " + selectedServer.getServerInfo(), + color = White, + fontSize = 12.sp + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + if (serviceState != SniCheckerServiceState.ACTIVE) { + Row(modifier = Modifier.fillMaxWidth()) { + Button( + onClick = onCancel, + modifier = Modifier.weight(1f).padding(end = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = White10, + contentColor = White, + ), + shape = RoundedCornerShape(100.dp), + ) { + Text(stringResource(R.string.cancel_button)) + } + Button( + onClick = onSave, + modifier = Modifier.weight(1f).padding(start = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Secondary, + contentColor = Primary, + ), + shape = RoundedCornerShape(100.dp), + ) { + Text(stringResource(R.string.save_button)) + } + } + } + } + } +} + +@Composable +private fun RadioMethodRow( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selected, + onClick = onClick, + colors = RadioButtonDefaults.colors( + selectedColor = Secondary, + unselectedColor = White, + ) + ) + Text(text = label, color = White, fontSize = 14.sp) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SniSpoofingDropdown( + current: SniSpoofingMode, + onSelected: (SniSpoofingMode) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val context = LocalContext.current + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = getSniModeName(context, current), + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = White, + unfocusedTextColor = White, + focusedContainerColor = White10, + unfocusedContainerColor = White10, + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + focusedTrailingIconColor = White, + unfocusedTrailingIconColor = White, + ), + shape = RoundedCornerShape(12.dp), + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(Primary), + ) { + SniSpoofingMode.entries.forEach { mode -> + DropdownMenuItem( + text = { Text(getSniModeName(context, mode), color = White) }, + onClick = { + onSelected(mode) + expanded = false + }, + modifier = Modifier.background(Primary), + ) + } + } + } +} + +@Composable +private fun EditSniDialog( + currentSni: String, + onSave: (String) -> Unit, + onReset: () -> Unit, + onDismiss: () -> Unit, +) { + var text by remember { mutableStateOf(currentSni) } + AlertDialog( + onDismissRequest = onDismiss, + containerColor = Primary, + title = { Text(stringResource(R.string.your_current_sni), color = White) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + placeholder = { Text(stringResource(R.string.sni_text_view_hint), color = White10) }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = White, + unfocusedTextColor = White, + focusedBorderColor = Secondary, + unfocusedBorderColor = White10, + cursorColor = White, + ), + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton(onClick = { onSave(text) }) { + Text(stringResource(R.string.save_button), color = Secondary) + } + }, + dismissButton = { + Row { + TextButton(onClick = onReset) { + Text(stringResource(R.string.reset_default_button), color = White) + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel_button), color = White) + } + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AutoSelectDialog( + servers: List, + onStart: (ServerEntity, Boolean) -> Unit, + onDismiss: () -> Unit, +) { + var selectedServer by remember { mutableStateOf(servers.firstOrNull()) } + var resetChecked by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + containerColor = Primary, + title = { Text(stringResource(R.string.select_server_for_autoselect), color = White) }, + text = { + Column { + if (servers.isNotEmpty()) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = selectedServer?.getServerInfo() ?: "", + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = White, + unfocusedTextColor = White, + focusedContainerColor = White10, + unfocusedContainerColor = White10, + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + focusedTrailingIconColor = White, + unfocusedTrailingIconColor = White, + ), + shape = RoundedCornerShape(12.dp), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(Primary), + ) { + servers.forEach { server -> + DropdownMenuItem( + text = { Text(server.getServerInfo(), color = White) }, + onClick = { + selectedServer = server + expanded = false + }, + modifier = Modifier.background(Primary), + ) + } + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = resetChecked, + onCheckedChange = { resetChecked = it }, + colors = CheckboxDefaults.colors( + checkedColor = Secondary, + checkmarkColor = Primary, + uncheckedColor = White, + ) + ) + Text( + text = stringResource(R.string.reset_checked_previously_checkbox_label), + color = White, + fontSize = 14.sp, + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { selectedServer?.let { onStart(it, resetChecked) } }, + enabled = selectedServer != null, + ) { + Text(stringResource(R.string.start_sni_checking_button_label), color = Secondary) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel_button), color = White) + } + } + ) +} + +private fun getSniModeName(context: android.content.Context, mode: SniSpoofingMode): String { + return when (mode) { + SniSpoofingMode.SNI -> context.getString(R.string.sni) + SniSpoofingMode.SNI_REALITY_CHROME_149 -> context.getString(R.string.sni_reality_radio_button_label_chrome_149) + SniSpoofingMode.SNI_REALITY_CHROME_148 -> context.getString(R.string.sni_reality_radio_button_label_chrome_148) + SniSpoofingMode.SNI_REALITY_CHROME_147 -> context.getString(R.string.sni_reality_radio_button_label_chrome_147) + SniSpoofingMode.SNI_REALITY_CHROME_146 -> context.getString(R.string.sni_reality_radio_button_label_chrome_146) + SniSpoofingMode.SNI_REALITY_CHROME_145 -> context.getString(R.string.sni_reality_radio_button_label_chrome_145) + SniSpoofingMode.SNI_REALITY_FIREFOX_151 -> context.getString(R.string.sni_reality_radio_button_label_firefox_151) + SniSpoofingMode.SNI_REALITY_FIREFOX_150 -> context.getString(R.string.sni_reality_radio_button_label_firefox_150) + SniSpoofingMode.SNI_REALITY_FIREFOX_149 -> context.getString(R.string.sni_reality_radio_button_label_firefox_149) + SniSpoofingMode.SNI_REALITY_YANDEX_26_4 -> context.getString(R.string.sni_reality_radio_button_label_yandex_26_4) + SniSpoofingMode.SNI_REALITY_YANDEX_26_3 -> context.getString(R.string.sni_reality_radio_button_label_yandex_26_3) + SniSpoofingMode.SNI_REALITY_YANDEX_25 -> context.getString(R.string.sni_reality_radio_button_label_yandex_25) + SniSpoofingMode.SNI_REALITY_YANDEX_24 -> context.getString(R.string.sni_reality_radio_button_label_yandex_24) + SniSpoofingMode.SNI_REALITY_SAFARI_26_5 -> context.getString(R.string.sni_reality_radio_button_label_safari_26_5) + SniSpoofingMode.SNI_REALITY_SAFARI_26_4 -> context.getString(R.string.sni_reality_radio_button_label_safari_26_4) + else -> mode.toString() + } +} diff --git a/app/src/main/java/org/fptn/vpn/ui/screens/ExperimentalSettingsScreen.kt b/app/src/main/java/org/fptn/vpn/ui/screens/ExperimentalSettingsScreen.kt new file mode 100644 index 00000000..4b8c1db9 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/ui/screens/ExperimentalSettingsScreen.kt @@ -0,0 +1,270 @@ +package org.fptn.vpn.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.fptn.vpn.R +import org.fptn.vpn.ui.theme.GrantedGreen +import org.fptn.vpn.ui.theme.Primary +import org.fptn.vpn.ui.theme.Secondary +import org.fptn.vpn.ui.theme.White +import org.fptn.vpn.ui.theme.White10 + +data class ExperimentalSettings( + val reconnectOnNetworkChange: Boolean, + val reconnectOnIpChange: Boolean, + val attemptsProgress: Int, + val delayProgress: Int, + val resetServerOnDisconnect: Boolean, + val resetServerOnException: Boolean, +) + +@Composable +fun ExperimentalSettingsScreen( + initial: ExperimentalSettings, + showTileButton: Boolean, + onRequestTile: () -> Unit, + onSave: (ExperimentalSettings) -> Unit, + onCancel: () -> Unit, +) { + val attemptsValues = intArrayOf(5, 15, 35, Int.MAX_VALUE) + + var reconnectOnNetworkChange by remember { mutableStateOf(initial.reconnectOnNetworkChange) } + var reconnectOnIpChange by remember { mutableStateOf(initial.reconnectOnIpChange) } + var attemptsProgress by remember { mutableFloatStateOf(initial.attemptsProgress.toFloat()) } + var delayProgress by remember { mutableFloatStateOf(initial.delayProgress.toFloat()) } + var resetOnDisconnect by remember { mutableStateOf(initial.resetServerOnDisconnect) } + var resetOnException by remember { mutableStateOf(initial.resetServerOnException) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(Primary) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.experimental_features_label), + color = White, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.experimental_features_info), + color = White, + fontSize = 14.sp, + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp) + ) + + Divider(color = White10) + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.reconnect_on_change), + color = Secondary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(8.dp)) + + SettingSwitchRow( + label = stringResource(R.string.reconnect_on_change_network_type), + checked = reconnectOnNetworkChange, + onCheckedChange = { reconnectOnNetworkChange = it }, + ) + SettingSwitchRow( + label = stringResource(R.string.reconnect_on_change_ip_address), + checked = reconnectOnIpChange, + onCheckedChange = { reconnectOnIpChange = it }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.reconnection_on_failure_attempts), + color = Secondary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(8.dp)) + + val attemptsLabel = attemptsValues.getOrNull(attemptsProgress.toInt()) + ?.let { if (it == Int.MAX_VALUE) "∞" else it.toString() } ?: "∞" + Text( + text = "${stringResource(R.string.attempts_count)} $attemptsLabel", + color = White, + fontSize = 14.sp, + ) + Slider( + value = attemptsProgress, + onValueChange = { attemptsProgress = it }, + valueRange = 0f..3f, + steps = 2, + colors = sliderColors(), + modifier = Modifier.fillMaxWidth(), + ) + + val delayLabel = "${delayProgress.toInt() + 1}s" + Text( + text = "${stringResource(R.string.delay_between_attempts)} $delayLabel", + color = White, + fontSize = 14.sp, + ) + Slider( + value = delayProgress, + onValueChange = { delayProgress = it }, + valueRange = 0f..29f, + steps = 28, + colors = sliderColors(), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.settings_reset_server_title), + color = Secondary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(8.dp)) + + SettingSwitchRow( + label = stringResource(R.string.settings_reset_server_on_disconnect), + checked = resetOnDisconnect, + onCheckedChange = { resetOnDisconnect = it }, + ) + if (!resetOnDisconnect) { + SettingSwitchRow( + label = stringResource(R.string.settings_reset_server_on_exception), + checked = resetOnException, + onCheckedChange = { resetOnException = it }, + ) + } + + if (showTileButton) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.quick_settings_tile), + color = Secondary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = onRequestTile, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = White10, + contentColor = White, + ), + shape = RoundedCornerShape(100.dp), + ) { + Text(stringResource(R.string.quick_settings_tile)) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + Row(modifier = Modifier.fillMaxWidth()) { + Button( + onClick = onCancel, + modifier = Modifier.weight(1f).padding(end = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = White10, + contentColor = White, + ), + shape = RoundedCornerShape(100.dp), + ) { + Text(stringResource(R.string.cancel_button)) + } + Button( + onClick = { + onSave( + ExperimentalSettings( + reconnectOnNetworkChange = reconnectOnNetworkChange, + reconnectOnIpChange = reconnectOnIpChange, + attemptsProgress = attemptsProgress.toInt(), + delayProgress = delayProgress.toInt(), + resetServerOnDisconnect = resetOnDisconnect, + resetServerOnException = resetOnException, + ) + ) + }, + modifier = Modifier.weight(1f).padding(start = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Secondary, + contentColor = Primary, + ), + shape = RoundedCornerShape(100.dp), + ) { + Text(stringResource(R.string.save_button)) + } + } + } +} + +@Composable +private fun SettingSwitchRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + color = White, + fontSize = 14.sp, + modifier = Modifier.weight(1f) + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = Primary, + checkedTrackColor = GrantedGreen, + uncheckedThumbColor = White, + uncheckedTrackColor = White10, + ) + ) + } + Divider(color = White10, thickness = 0.5.dp) +} + +@Composable +private fun sliderColors() = SliderDefaults.colors( + thumbColor = Secondary, + activeTrackColor = Secondary, + inactiveTrackColor = White10, +) diff --git a/app/src/main/java/org/fptn/vpn/ui/screens/HomeScreen.kt b/app/src/main/java/org/fptn/vpn/ui/screens/HomeScreen.kt new file mode 100644 index 00000000..ccf5c0fb --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/ui/screens/HomeScreen.kt @@ -0,0 +1,325 @@ +package org.fptn.vpn.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.fptn.vpn.R +import org.fptn.vpn.database.entity.ServerEntity +import org.fptn.vpn.enums.ConnectionState +import org.fptn.vpn.ui.components.BottomNavBar +import org.fptn.vpn.ui.components.BottomNavTab +import org.fptn.vpn.ui.theme.DeniedRed +import org.fptn.vpn.ui.theme.Primary +import org.fptn.vpn.ui.theme.Secondary +import org.fptn.vpn.ui.theme.White +import org.fptn.vpn.ui.theme.White10 +import org.fptn.vpn.ui.theme.Yellow +import org.fptn.vpn.utils.CountryFlags + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + serverList: List, + selectedServer: ServerEntity?, + connectionState: ConnectionState, + statusText: String, + errorText: String, + timerText: String, + downloadSpeed: String, + uploadSpeed: String, + connectedServerInfo: String, + showPermissionWarning: Boolean, + settingsEnabled: Boolean, + onServerSelected: (ServerEntity) -> Unit, + onConnectDisconnect: () -> Unit, + onHome: () -> Unit, + onSettings: () -> Unit, +) { + val context = LocalContext.current + val isConnected = connectionState == ConnectionState.CONNECTED + val isActive = connectionState.isActiveState() + + Scaffold( + containerColor = Primary, + bottomBar = { + BottomNavBar( + current = BottomNavTab.HOME, + onHome = onHome, + onSettings = { if (settingsEnabled) onSettings() }, + context = context, + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(Primary) + .padding(padding) + .padding(horizontal = 16.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = statusText, + color = White, + fontSize = 16.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + if (errorText.isNotEmpty()) { + Text( + text = errorText, + color = Yellow, + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + ConnectButton( + isActive = isActive, + connectionState = connectionState, + onClick = onConnectDisconnect, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + if (!isActive && serverList.isNotEmpty()) { + ServerDropdown( + servers = serverList, + selected = selectedServer, + onSelected = onServerSelected, + ) + } + + if (isConnected) { + Spacer(modifier = Modifier.height(16.dp)) + + ConnectedInfoCard( + timerText = timerText, + downloadSpeed = downloadSpeed, + uploadSpeed = uploadSpeed, + connectedServerInfo = connectedServerInfo, + ) + } + + if (showPermissionWarning) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.permission_warning_text), + color = DeniedRed, + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .background(Color(0x37000000), RoundedCornerShape(8.dp)) + .padding(8.dp) + ) + } + } + } +} + +@Composable +private fun ConnectButton( + isActive: Boolean, + connectionState: ConnectionState, + onClick: () -> Unit, +) { + val borderColor = if (isActive) Secondary else White10 + val innerColor = if (isActive) Secondary else White10 + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(180.dp) + .clip(CircleShape) + .border(4.dp, borderColor, CircleShape) + ) { + Button( + onClick = onClick, + modifier = Modifier.size(148.dp), + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = innerColor, + contentColor = Primary, + ), + ) { + Icon( + painter = painterResource( + if (isActive) R.drawable.ic_baseline_logout_24 + else R.drawable.ic_baseline_update_24 + ), + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = Primary, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ServerDropdown( + servers: List, + selected: ServerEntity?, + onSelected: (ServerEntity) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val displayText = selected?.let { serverDisplayName(it) } ?: stringResource(R.string.select_server) + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = displayText, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = White, + unfocusedTextColor = White, + focusedContainerColor = White10, + unfocusedContainerColor = White10, + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + focusedTrailingIconColor = White, + unfocusedTrailingIconColor = White, + ), + shape = RoundedCornerShape(20.dp), + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(Primary), + ) { + servers.forEach { server -> + DropdownMenuItem( + text = { + Text( + text = serverDisplayName(server), + color = White, + ) + }, + onClick = { + onSelected(server) + expanded = false + }, + modifier = Modifier.background(Primary), + ) + } + } + } +} + +private fun serverDisplayName(server: ServerEntity): String { + if (server.isAuto) return server.name + val flag = CountryFlags.getCountryFlagByCountryCode(server.countryCode) + val pingStr = when { + server.pingMs > 0 -> " ${getPingEmoji(server.pingMs)} ${server.pingMs}ms" + server.pingMs < 0 -> " ✗" + else -> "" + } + return "${flag ?: ""} ${server.name}$pingStr" +} + +private fun getPingEmoji(ping: Long) = when { + ping < 150 -> "🟢" + ping < 200 -> "🟡" + ping < 300 -> "🟠" + else -> "🔴" +} + +@Composable +private fun ConnectedInfoCard( + timerText: String, + downloadSpeed: String, + uploadSpeed: String, + connectedServerInfo: String, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(White10, RoundedCornerShape(16.dp)) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = "↓ $downloadSpeed", color = White, fontSize = 14.sp) + } + Text( + text = timerText, + color = Secondary, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = "↑ $uploadSpeed", color = White, fontSize = 14.sp) + } + } + if (connectedServerInfo.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = connectedServerInfo, + color = White, + fontSize = 14.sp, + textAlign = TextAlign.Center, + ) + } + } +} + +private val ServerEntity.isAuto: Boolean + get() = name == "Auto" diff --git a/app/src/main/java/org/fptn/vpn/ui/screens/LoginScreen.kt b/app/src/main/java/org/fptn/vpn/ui/screens/LoginScreen.kt new file mode 100644 index 00000000..0685743a --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/ui/screens/LoginScreen.kt @@ -0,0 +1,134 @@ +package org.fptn.vpn.ui.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.fptn.vpn.R +import org.fptn.vpn.ui.components.HtmlText +import org.fptn.vpn.ui.theme.Hint +import org.fptn.vpn.ui.theme.Primary +import org.fptn.vpn.ui.theme.Secondary +import org.fptn.vpn.ui.theme.White +import org.fptn.vpn.ui.theme.White10 +import org.fptn.vpn.ui.theme.Yellow + +@Composable +fun LoginScreen( + errorText: String, + htmlLabel: String, + onLogin: (String) -> Unit, +) { + var tokenInput by remember { mutableStateOf(TextFieldValue("")) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(Primary) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.icon_account_circle_100), + contentDescription = null, + modifier = Modifier + .padding(top = 48.dp) + .size(160.dp) + ) + + HtmlText( + html = htmlLabel, + modifier = Modifier + .fillMaxWidth() + .padding(top = 32.dp), + textColor = White, + textSizeSp = 16f, + ) + + OutlinedTextField( + value = tokenInput, + onValueChange = { tokenInput = it }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + placeholder = { + Text( + text = stringResource(R.string.paste_your_token), + color = Hint, + fontSize = 16.sp + ) + }, + trailingIcon = { + if (tokenInput.text.isNotEmpty()) { + IconButton(onClick = { tokenInput = TextFieldValue("") }) { + Icon( + painter = painterResource(R.drawable.ic_baseline_close_24), + contentDescription = null, + tint = White + ) + } + } + }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = White, + unfocusedTextColor = White, + focusedContainerColor = White10, + unfocusedContainerColor = White10, + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + cursorColor = White, + ), + shape = RoundedCornerShape(20.dp), + singleLine = true, + ) + + Button( + onClick = { onLogin(tokenInput.text) }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 32.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Secondary, + contentColor = Primary, + ), + shape = RoundedCornerShape(100.dp), + ) { + Text( + text = stringResource(R.string.login_button), + fontSize = 18.sp + ) + } + + if (errorText.isNotEmpty()) { + Text( + text = errorText, + color = Yellow, + modifier = Modifier.padding(top = 16.dp, start = 5.dp, end = 20.dp, bottom = 20.dp) + ) + } + } +} diff --git a/app/src/main/java/org/fptn/vpn/ui/screens/LogsScreen.kt b/app/src/main/java/org/fptn/vpn/ui/screens/LogsScreen.kt new file mode 100644 index 00000000..e937f90d --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/ui/screens/LogsScreen.kt @@ -0,0 +1,61 @@ +package org.fptn.vpn.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.fptn.vpn.ui.components.BottomNavBar +import org.fptn.vpn.ui.components.BottomNavTab +import org.fptn.vpn.ui.theme.Primary +import org.fptn.vpn.ui.theme.White + +@Composable +fun LogsScreen( + logsText: String, + onCopyLogs: () -> Unit, + onHome: () -> Unit, + onSettings: () -> Unit, +) { + val context = LocalContext.current + + Scaffold( + containerColor = Primary, + bottomBar = { + BottomNavBar( + current = BottomNavTab.SETTINGS, + onHome = onHome, + onSettings = onSettings, + context = context, + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(Primary) + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = logsText, + color = White, + fontSize = 12.sp, + modifier = Modifier + .fillMaxWidth() + .clickable { onCopyLogs() } + ) + } + } +} diff --git a/app/src/main/java/org/fptn/vpn/ui/screens/PerAppVpnScreen.kt b/app/src/main/java/org/fptn/vpn/ui/screens/PerAppVpnScreen.kt new file mode 100644 index 00000000..f37807c5 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/ui/screens/PerAppVpnScreen.kt @@ -0,0 +1,308 @@ +package org.fptn.vpn.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import org.fptn.vpn.R +import org.fptn.vpn.enums.PerAppVpnMode +import org.fptn.vpn.ui.components.BottomNavBar +import org.fptn.vpn.ui.components.BottomNavTab +import org.fptn.vpn.ui.theme.GrantedGreen +import org.fptn.vpn.ui.theme.Primary +import org.fptn.vpn.ui.theme.Secondary +import org.fptn.vpn.ui.theme.White +import org.fptn.vpn.ui.theme.White10 +import org.fptn.vpn.views.perappvpn.AppInfo +import androidx.compose.foundation.Image + +@Composable +fun PerAppVpnScreen( + mode: PerAppVpnMode, + appList: List, + isLoading: Boolean, + showSystemApps: Boolean, + onModeChange: (PerAppVpnMode) -> Unit, + onAppToggle: (AppInfo) -> Unit, + onShowSystemAppsChange: (Boolean) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, + onHome: () -> Unit, + onSettings: () -> Unit, +) { + val context = LocalContext.current + var searchQuery by remember { mutableStateOf("") } + + val filteredApps = remember(appList, searchQuery, mode) { + appList + .filter { app -> + searchQuery.isEmpty() || + app.label.lowercase().contains(searchQuery.lowercase()) + } + .sortedWith( + compareByDescending { app -> + when (mode) { + PerAppVpnMode.ONLY_ALLOWED -> app.isAllowed + PerAppVpnMode.EXCEPT_DISALLOWED -> app.isDisallowed + else -> false + } + }.thenBy { it.label } + ) + } + + Scaffold( + containerColor = Primary, + bottomBar = { + BottomNavBar( + current = BottomNavTab.SETTINGS, + onHome = onHome, + onSettings = onSettings, + context = context, + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(Primary) + .padding(padding) + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = stringResource(R.string.per_app_vpn_settings_title), + color = White, + fontSize = 18.sp, + modifier = Modifier.padding(bottom = 4.dp) + ) + Text( + text = stringResource(R.string.per_app_vpn_mode_info), + color = White, + fontSize = 12.sp, + modifier = Modifier.padding(bottom = 8.dp) + ) + + PerAppModeRadioRow( + label = stringResource(R.string.all_app_mode_radio_button_label), + selected = mode == PerAppVpnMode.OFF, + onClick = { onModeChange(PerAppVpnMode.OFF) } + ) + PerAppModeRadioRow( + label = stringResource(R.string.only_allowed_apps_mode_radio_button_label), + selected = mode == PerAppVpnMode.ONLY_ALLOWED, + onClick = { onModeChange(PerAppVpnMode.ONLY_ALLOWED) } + ) + PerAppModeRadioRow( + label = stringResource(R.string.disallowed_apps_radio_button_label), + selected = mode == PerAppVpnMode.EXCEPT_DISALLOWED, + onClick = { onModeChange(PerAppVpnMode.EXCEPT_DISALLOWED) } + ) + } + + if (mode != PerAppVpnMode.OFF) { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { + Text(stringResource(R.string.search_apps_hint), color = White10) + }, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = White, + unfocusedTextColor = White, + focusedContainerColor = White10, + unfocusedContainerColor = White10, + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + cursorColor = White, + ), + shape = RoundedCornerShape(20.dp), + singleLine = true, + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.show_system_apps_label), + color = White, + fontSize = 14.sp, + modifier = Modifier.weight(1f) + ) + Switch( + checked = showSystemApps, + onCheckedChange = onShowSystemAppsChange, + colors = SwitchDefaults.colors( + checkedThumbColor = Primary, + checkedTrackColor = GrantedGreen, + uncheckedThumbColor = White, + uncheckedTrackColor = White10, + ) + ) + } + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = Secondary) + } + } else { + LazyColumn(modifier = Modifier.weight(1f)) { + items( + items = filteredApps, + key = { it.packageName } + ) { app -> + AppItemRow( + app = app, + mode = mode, + onToggle = { onAppToggle(app) } + ) + } + } + } + } else { + Spacer(modifier = Modifier.weight(1f)) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Button( + onClick = onCancel, + modifier = Modifier.weight(1f).padding(end = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = White10, + contentColor = White, + ), + shape = RoundedCornerShape(100.dp), + ) { + Text(stringResource(R.string.cancel_button)) + } + Button( + onClick = onSave, + modifier = Modifier.weight(1f).padding(start = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Secondary, + contentColor = Primary, + ), + shape = RoundedCornerShape(100.dp), + ) { + Text(stringResource(R.string.save_button)) + } + } + } + } +} + +@Composable +private fun PerAppModeRadioRow( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selected, + onClick = onClick, + colors = RadioButtonDefaults.colors( + selectedColor = Secondary, + unselectedColor = White, + ) + ) + Text(text = label, color = White, fontSize = 14.sp) + } +} + +@Composable +private fun AppItemRow( + app: AppInfo, + mode: PerAppVpnMode, + onToggle: () -> Unit, +) { + val isChecked = when (mode) { + PerAppVpnMode.ONLY_ALLOWED -> app.isAllowed + PerAppVpnMode.EXCEPT_DISALLOWED -> app.isDisallowed + else -> false + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (app.icon != null) { + Image( + painter = rememberDrawablePainter(app.icon), + contentDescription = null, + modifier = Modifier + .size(40.dp) + .padding(end = 8.dp) + ) + } else { + Box(modifier = Modifier.size(40.dp).padding(end = 8.dp)) + } + Text( + text = app.label, + color = White, + fontSize = 14.sp, + modifier = Modifier.weight(1f) + ) + Switch( + checked = isChecked, + onCheckedChange = { onToggle() }, + colors = SwitchDefaults.colors( + checkedThumbColor = Primary, + checkedTrackColor = Secondary, + uncheckedThumbColor = White, + uncheckedTrackColor = White10, + ) + ) + } + Divider(color = White10, thickness = 0.5.dp) +} diff --git a/app/src/main/java/org/fptn/vpn/ui/screens/SettingsScreen.kt b/app/src/main/java/org/fptn/vpn/ui/screens/SettingsScreen.kt new file mode 100644 index 00000000..b63ca043 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/ui/screens/SettingsScreen.kt @@ -0,0 +1,304 @@ +package org.fptn.vpn.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.fptn.vpn.R +import org.fptn.vpn.database.entity.ServerEntity +import org.fptn.vpn.ui.components.BottomNavBar +import org.fptn.vpn.ui.components.BottomNavTab +import org.fptn.vpn.ui.components.HtmlText +import org.fptn.vpn.ui.theme.GrantedGreen +import org.fptn.vpn.ui.theme.Hint +import org.fptn.vpn.ui.theme.Primary +import org.fptn.vpn.ui.theme.Secondary +import org.fptn.vpn.ui.theme.White +import org.fptn.vpn.ui.theme.White10 +import org.fptn.vpn.utils.CountryFlags + +@Composable +fun SettingsScreen( + serverList: List, + appVersion: String, + aboutHtml: String, + tokenInfoHtml: String, + sponsorsHtml: String, + batteryOptGranted: Boolean, + backgroundDataGranted: Boolean, + onBatteryOptClick: () -> Unit, + onBackgroundDataClick: () -> Unit, + onUpdateToken: () -> Unit, + onBypassMethods: () -> Unit, + onPerAppVpn: () -> Unit, + onExperimentalSettings: () -> Unit, + onLogs: () -> Unit, + onLogout: () -> Unit, + onHome: () -> Unit, + onSettings: () -> Unit, +) { + val context = LocalContext.current + + Scaffold( + containerColor = Primary, + bottomBar = { + BottomNavBar( + current = BottomNavTab.SETTINGS, + onHome = onHome, + onSettings = onSettings, + context = context, + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(Primary) + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + SectionHeader(text = stringResource(R.string.your_servers)) + + serverList.forEach { server -> + ServerListItem(server = server) + } + + Spacer(modifier = Modifier.height(16.dp)) + + SettingsNavigationItem( + icon = R.drawable.ic_baseline_update_24, + title = stringResource(R.string.update_token_button), + onClick = onUpdateToken, + ) + + SettingsNavigationItem( + icon = R.drawable.ic_baseline_your_servers_24, + title = stringResource(R.string.bypass_methods_title), + onClick = onBypassMethods, + ) + + SettingsNavigationItem( + icon = R.drawable.ic_per_app_vpn_mode, + title = stringResource(R.string.per_app_vpn_mode_title), + onClick = onPerAppVpn, + ) + + SettingsNavigationItem( + icon = R.drawable.ic_experimental_features_24, + title = stringResource(R.string.experimental_features_label), + onClick = onExperimentalSettings, + ) + + SettingsNavigationItem( + icon = R.drawable.ic_baseline_menu_24, + title = stringResource(R.string.logs), + onClick = onLogs, + ) + + Spacer(modifier = Modifier.height(16.dp)) + SectionHeader(text = stringResource(R.string.required_permissions)) + + PermissionSwitch( + label = stringResource(R.string.battery_optimization_request_dialog_title), + granted = batteryOptGranted, + onClick = onBatteryOptClick, + ) + + PermissionSwitch( + label = stringResource(R.string.background_data_request_dialog_title), + granted = backgroundDataGranted, + onClick = onBackgroundDataClick, + ) + + Spacer(modifier = Modifier.height(16.dp)) + SectionHeader(text = stringResource(R.string.sponsors_text)) + + HtmlText( + html = sponsorsHtml, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + textColor = White, + textSizeSp = 14f, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + HtmlText( + html = tokenInfoHtml, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + textColor = White, + textSizeSp = 14f, + ) + + HtmlText( + html = aboutHtml, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + textColor = White, + textSizeSp = 14f, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = appVersion, + color = Hint, + fontSize = 12.sp, + modifier = Modifier.padding(vertical = 4.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SettingsNavigationItem( + icon = R.drawable.ic_baseline_logout_24, + title = stringResource(R.string.logout_button), + onClick = onLogout, + tint = Color(0xFFCC0000), + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + color = Secondary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(vertical = 8.dp) + ) + Divider(color = White10, thickness = 1.dp) + Spacer(modifier = Modifier.height(8.dp)) +} + +@Composable +private fun ServerListItem(server: ServerEntity) { + val flag = CountryFlags.getCountryFlagByCountryCode(server.countryCode) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (flag != null) { + Text(text = flag, fontSize = 20.sp, modifier = Modifier.padding(end = 8.dp)) + } + Column { + Text(text = server.name, color = White, fontSize = 14.sp) + Text(text = server.host, color = Hint, fontSize = 12.sp) + } + } + } + Divider(color = White10, thickness = 0.5.dp) +} + +@Composable +private fun SettingsNavigationItem( + icon: Int, + title: String, + onClick: () -> Unit, + tint: Color = White, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = tint, + modifier = Modifier + .size(24.dp) + .padding(end = 0.dp) + ) + Text( + text = title, + color = tint, + fontSize = 16.sp, + modifier = Modifier.padding(start = 16.dp) + ) + } + Icon( + painter = painterResource(R.drawable.ic_outline_arrow_forward_ios_24), + contentDescription = null, + tint = White10, + modifier = Modifier.size(16.dp) + ) + } + Divider(color = White10, thickness = 0.5.dp) +} + +@Composable +private fun PermissionSwitch( + label: String, + granted: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + color = White, + fontSize = 14.sp, + modifier = Modifier.weight(1f) + ) + Switch( + checked = granted, + onCheckedChange = { if (!granted) onClick() }, + enabled = !granted, + colors = SwitchDefaults.colors( + checkedThumbColor = Primary, + checkedTrackColor = GrantedGreen, + uncheckedThumbColor = White, + uncheckedTrackColor = White10, + ) + ) + } + Divider(color = White10, thickness = 0.5.dp) +} diff --git a/app/src/main/java/org/fptn/vpn/ui/screens/SplashScreen.kt b/app/src/main/java/org/fptn/vpn/ui/screens/SplashScreen.kt new file mode 100644 index 00000000..6aec0082 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/ui/screens/SplashScreen.kt @@ -0,0 +1,30 @@ +package org.fptn.vpn.ui.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import org.fptn.vpn.R +import org.fptn.vpn.ui.theme.Primary + +@Composable +fun SplashScreen() { + Box( + modifier = Modifier + .fillMaxSize() + .background(Primary), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.ic_logo), + contentDescription = null, + modifier = Modifier.size(160.dp) + ) + } +} diff --git a/app/src/main/java/org/fptn/vpn/ui/screens/UpdateTokenScreen.kt b/app/src/main/java/org/fptn/vpn/ui/screens/UpdateTokenScreen.kt new file mode 100644 index 00000000..e795a386 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/ui/screens/UpdateTokenScreen.kt @@ -0,0 +1,168 @@ +package org.fptn.vpn.ui.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.fptn.vpn.R +import org.fptn.vpn.ui.components.BottomNavBar +import org.fptn.vpn.ui.components.BottomNavTab +import org.fptn.vpn.ui.components.HtmlText +import org.fptn.vpn.ui.theme.Hint +import org.fptn.vpn.ui.theme.Primary +import org.fptn.vpn.ui.theme.Secondary +import org.fptn.vpn.ui.theme.White +import org.fptn.vpn.ui.theme.White10 +import org.fptn.vpn.ui.theme.Yellow + +@Composable +fun UpdateTokenScreen( + errorText: String, + htmlLabel: String, + onSave: (String) -> Unit, + onCancel: () -> Unit, + onHome: () -> Unit, + onSettings: () -> Unit, +) { + val context = LocalContext.current + var tokenInput by remember { mutableStateOf(TextFieldValue("")) } + + Scaffold( + containerColor = Primary, + bottomBar = { + BottomNavBar( + current = BottomNavTab.SETTINGS, + onHome = onHome, + onSettings = onSettings, + context = context, + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(Primary) + .padding(padding) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.icon_account_circle_100), + contentDescription = null, + modifier = Modifier + .padding(top = 32.dp) + .size(120.dp) + ) + + HtmlText( + html = htmlLabel, + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp), + textColor = White, + textSizeSp = 16f, + ) + + OutlinedTextField( + value = tokenInput, + onValueChange = { tokenInput = it }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + placeholder = { + Text(stringResource(R.string.paste_your_token), color = Hint, fontSize = 16.sp) + }, + trailingIcon = { + if (tokenInput.text.isNotEmpty()) { + IconButton(onClick = { tokenInput = TextFieldValue("") }) { + Icon( + painter = painterResource(R.drawable.ic_baseline_close_24), + contentDescription = null, + tint = White + ) + } + } + }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = White, + unfocusedTextColor = White, + focusedContainerColor = White10, + unfocusedContainerColor = White10, + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + cursorColor = White, + ), + shape = RoundedCornerShape(20.dp), + singleLine = true, + ) + + if (errorText.isNotEmpty()) { + Text( + text = errorText, + color = Yellow, + modifier = Modifier.padding(top = 8.dp, start = 5.dp, end = 20.dp) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp) + ) { + Button( + onClick = onCancel, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = White10, + contentColor = White, + ), + shape = RoundedCornerShape(100.dp), + ) { + Text(stringResource(R.string.cancel_button)) + } + Button( + onClick = { onSave(tokenInput.text) }, + modifier = Modifier + .weight(1f) + .padding(start = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Secondary, + contentColor = Primary, + ), + shape = RoundedCornerShape(100.dp), + ) { + Text(stringResource(R.string.save_button)) + } + } + } + } +} diff --git a/app/src/main/java/org/fptn/vpn/ui/theme/FptnTheme.kt b/app/src/main/java/org/fptn/vpn/ui/theme/FptnTheme.kt new file mode 100644 index 00000000..a73f5b4a --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/ui/theme/FptnTheme.kt @@ -0,0 +1,41 @@ +package org.fptn.vpn.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +val Primary = Color(0xFF1B023B) +val Secondary = Color(0xFF03DAC5) +val White = Color(0xFFFFFFFF) +val Yellow = Color(0xFFBCBC1C) +val Hint = Color(0xFF999E9D) +val White10 = Color(0x1AFFFFFF) +val DeniedRed = Color(0xFFCC0000) +val GrantedGreen = Color(0xFF64DD17) + +private val FptnColors = darkColorScheme( + primary = Secondary, + onPrimary = Primary, + primaryContainer = Secondary, + onPrimaryContainer = Primary, + secondary = Secondary, + onSecondary = Primary, + background = Primary, + onBackground = White, + surface = Primary, + onSurface = White, + surfaceVariant = White10, + onSurfaceVariant = White, + outline = White10, + error = Yellow, + onError = Primary, +) + +@Composable +fun FptnTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = FptnColors, + content = content + ) +} 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 deleted file mode 100644 index 1a056568..00000000 --- a/app/src/main/java/org/fptn/vpn/views/bypassmethod/BypassMethodsActivity.java +++ /dev/null @@ -1,476 +0,0 @@ -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.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -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.elvishew.xlog.XLog; -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.enums.SniSpoofingMode; -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 View sniLayout; - - private BypassMethodsViewModel viewModel; - private ActivityResultLauncher filePickerLauncher; - private AlertDialog autoSelectDialog; - - private ServiceConnection connection; - - @Override - protected void onStart() { - super.onStart(); - - connection = new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName name, IBinder service) { - XLog.tag(TAG).i("SNI checker service connected [component=%s]", name.getShortClassName()); - 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) { - XLog.tag(TAG).e("Error handling SNI checker service disconnect: %s", e.getMessage()); - } - } - }; - SniCheckerService.bindService(this, connection); - } - - @Override - protected void onStop() { - super.onStop(); - - try { - if (connection != null) { - unbindService(connection); - } - } catch (Exception e) { - XLog.tag(TAG).e("Error unbinding SNI checker service: %s", e.getMessage()); - } - } - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.settings_bypass_methods_layout); - - initializeVariable(); - } - - @SuppressLint("InlinedApi") - private void initializeVariable() { - viewModel = new ViewModelProvider(this).get(BypassMethodsViewModel.class); - - sniLayout = findViewById(R.id.sni_layout); - - BottomNavigationView bottomNavigationView = findViewById(R.id.bottomNavBar); - bottomNavigationView.setSelectedItemId(R.id.menuSettings); - bottomNavigationView.setOnItemSelectedListener(new CustomBottomNavigationListener(this, R.id.menuSettings)); - - // Setup RadioGroup listener - RadioGroup protocolRadioGroup = findViewById(R.id.bypass_method_radio_button_group); - protocolRadioGroup.setOnCheckedChangeListener((group, checkedId) -> { - if (checkedId == R.id.sni_reality_radio_button) { - XLog.tag(TAG).i("Bypass method selected [method=SNI_REALITY]"); - viewModel.setBypassMethod(BypassCensorshipMethod.SNI_REALITY); - } else if (checkedId == R.id.obfuscation_radio_button) { - XLog.tag(TAG).i("Bypass method selected [method=TLS_OBFUSCATION]"); - viewModel.setBypassMethod(BypassCensorshipMethod.TLS_OBFUSCATION); - } - }); - - RadioButton sniSpoofingRadioButton = findViewById(R.id.sni_reality_radio_button); - RadioButton obfuscationRadioButton = findViewById(R.id.obfuscation_radio_button); - - // Inside initializeVariable() method - Spinner sniSpoofingModeSpinner = findViewById(R.id.sni_spoofing_mode_spinner); - ArrayAdapter adapter = new ArrayAdapter<>( - this, - R.layout.sni_mode_spinner_item, - R.id.sni_mode_label, - SniSpoofingMode.values() - ) { - @Override - public View getView(int position, View convertView, ViewGroup parent) { - TextView textView = (TextView) super.getView(position, convertView, parent); - textView.setText(getSniSpoofingModeFriendlyName(getItem(position))); - return textView; - } - - @Override - public View getDropDownView(int position, View convertView, ViewGroup parent) { - TextView textView = (TextView) super.getDropDownView(position, convertView, parent); - textView.setText(getSniSpoofingModeFriendlyName(getItem(position))); - return textView; - } - }; - sniSpoofingModeSpinner.setAdapter(adapter); - - View sniAutoscanLayout = findViewById(R.id.sni_autoscan_layout); - - viewModel.getBypassCensorshipMethodMutableLiveData().observe(this, bypassCensorshipMethod -> { - switch (bypassCensorshipMethod) { - case TLS_OBFUSCATION: - obfuscationRadioButton.setChecked(true); - ViewUtils.hideView(sniLayout); - ViewUtils.hideView(sniSpoofingModeSpinner); - ViewUtils.hideView(sniAutoscanLayout); - break; - case SNI_REALITY: - sniSpoofingRadioButton.setChecked(true); - ViewUtils.showView(sniLayout); - ViewUtils.showView(sniSpoofingModeSpinner); - ViewUtils.showView(sniAutoscanLayout); - break; - } - }); - - - viewModel.getSniSpoofingModeMutableLiveData().observe(this, mode -> { - int position = adapter.getPosition(mode); - if (position >= 0) { - sniSpoofingModeSpinner.setSelection(position); - } - }); - - sniSpoofingModeSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(android.widget.AdapterView parent, View view, int position, long id) { - SniSpoofingMode selectedMode = (SniSpoofingMode) parent.getItemAtPosition(position); - viewModel.setSniSpoofingMode(selectedMode); - } - - @Override - public void onNothingSelected(android.widget.AdapterView parent) { - } - }); - - - // SNI field - TextView sniTextField = findViewById(R.id.SNI_text_field); - viewModel.getSniMutableLiveData().observe(this, sniTextField::setText); - - 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 -> { - XLog.tag(TAG).i("Bypass settings cancelled"); - - finish(); - }); - - Button saveButton = findViewById(R.id.save_button); - saveButton.setOnClickListener(v -> { - XLog.tag(TAG).i("Bypass settings saved [method=%s]", viewModel.getBypassCensorshipMethodMutableLiveData().getValue()); - - viewModel.saveBypassMethod(); - - finish(); - }); - - // SNI Auto - TextView sniCountLabel = findViewById(R.id.loaded_sni_count_label); - - // Remove for temporary - // Button loadSniButton = findViewById(R.id.load_sni_button); - // loadSniButton.setOnClickListener(view -> onLoadButtonClicked()); - // Button deleteOrResetToDefaultSniButton = findViewById(R.id.delete_or_reset_sni_button); - // deleteOrResetToDefaultSniButton.setOnClickListener(view -> onDeleteOrResetSniButtonClicked()); - try { - viewModel.loadDefaultSni(); // Load default SNI - } catch (PVNClientException err) { - XLog.tag(TAG).w("Failed to load default SNI list: %s", err.errorMessage); - } - 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); - - // 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(cancelSaveButtonsView); - - ViewUtils.showView(checkingInProgressView); - - startStopCheckingSniButton.setChecked(true); - } else { - 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; - //deleteOrResetToDefaultSniButton.setText(count > 0 ? R.string.delete_loaded_sni : R.string.load_default); - 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(); - XLog.tag(TAG).i("SNI file selected [path=%s]", uri.getPath()); - try { - viewModel.readFileContent(uri); - } catch (PVNClientException e) { - Toast.makeText(BypassMethodsActivity.this, e.errorMessage, Toast.LENGTH_SHORT).show(); - } - } - } else { - XLog.tag(TAG).i("SNI file selection cancelled"); - } - }); - - viewModel.getFoundedSniEvent().observe(this, sni -> { - if (sni != null) { - Toast.makeText(this, "Found SNI: " + sni, Toast.LENGTH_LONG).show(); - } - }); - } - - 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) { - XLog.tag(TAG).e("Failed to load server list for SNI auto-select: %s", t.getMessage()); - } - }, 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 -> { - XLog.tag(TAG).i("SNI auto-select cancelled by user"); - - ToggleButton startStopCheckingSniButton = findViewById(R.id.auto_select_sni_button); - startStopCheckingSniButton.setChecked(false); - - autoSelectDialog.dismiss(); - }); - - buttonStart.setOnClickListener(v -> { - // Get the originally selected server object - int selectedPosition = serverSpinner.getSelectedItemPosition(); - ServerEntity selectedServer = serverEntities.get(selectedPosition); - - XLog.tag(TAG).i("Starting SNI auto-select [server=%s, resetChecked=%b]", selectedServer.getServerInfo(), resetCheckedCheckbox.isChecked()); - SniCheckerService.startChecking(this, selectedServer, resetCheckedCheckbox.isChecked(), - viewModel.getBypassCensorshipMethodMutableLiveData().getValue()); - - autoSelectDialog.dismiss(); - }); - - autoSelectDialog.show(); - } - - private void onDeleteOrResetSniButtonClicked() { - // todo: if default sni files will more than two - maybe add selector in dialog, which to load - int sniCount = viewModel.getSniCountLiveData().getValue(); - if (sniCount > 0) { - viewModel.deleteAllSni(); - } else { - try { - viewModel.loadDefaultSni(); - } catch (PVNClientException e) { - Toast.makeText(BypassMethodsActivity.this, e.errorMessage, Toast.LENGTH_SHORT).show(); - } - } - } - - private void onLoadButtonClicked() { - // Create an intent to open the file picker - Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.setType("*/*"); // Allow all types - 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) { - View inflated = View.inflate(this, R.layout.sni_dialog_layout, null); - TextInputEditText sniEditText = inflated.findViewById(R.id.text_edit_sni); - sniEditText.setText(viewModel.getCurrentSni()); - - AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(this); - alertDialogBuilder.setView(inflated); - alertDialogBuilder.setPositiveButton(R.string.save_button, (dialog, which) -> { - XLog.tag(TAG).i("SNI edit saved [sni=%s]", sniEditText.getText()); - Optional.ofNullable(sniEditText.getText()) - .map(Object::toString) - .ifPresent(viewModel::validateAndSetSni); - }); - alertDialogBuilder.setNeutralButton(getString(R.string.reset_default_button), (dialog, which) -> { - XLog.tag(TAG).i("SNI reset to default"); - viewModel.resetToDefault(); - }); - alertDialogBuilder.setNegativeButton(getString(R.string.cancel_button), (dialog, which) -> { - XLog.tag(TAG).i("SNI edit cancelled"); - }); - alertDialogBuilder.show(); - } - - private String getSniSpoofingModeFriendlyName(SniSpoofingMode mode) { - return switch (mode) { - case SNI -> getString(R.string.sni); - case SNI_REALITY_CHROME_149 -> - getString(R.string.sni_reality_radio_button_label_chrome_149); - case SNI_REALITY_CHROME_148 -> - getString(R.string.sni_reality_radio_button_label_chrome_148); - case SNI_REALITY_CHROME_147 -> - getString(R.string.sni_reality_radio_button_label_chrome_147); - case SNI_REALITY_CHROME_146 -> - getString(R.string.sni_reality_radio_button_label_chrome_146); - case SNI_REALITY_CHROME_145 -> - getString(R.string.sni_reality_radio_button_label_chrome_145); - case SNI_REALITY_FIREFOX_151 -> - getString(R.string.sni_reality_radio_button_label_firefox_151); - case SNI_REALITY_FIREFOX_150 -> - getString(R.string.sni_reality_radio_button_label_firefox_150); - case SNI_REALITY_FIREFOX_149 -> - getString(R.string.sni_reality_radio_button_label_firefox_149); - case SNI_REALITY_YANDEX_26_4 -> - getString(R.string.sni_reality_radio_button_label_yandex_26_4); - case SNI_REALITY_YANDEX_26_3 -> - getString(R.string.sni_reality_radio_button_label_yandex_26_3); - case SNI_REALITY_YANDEX_25 -> - getString(R.string.sni_reality_radio_button_label_yandex_25); - case SNI_REALITY_YANDEX_24 -> - getString(R.string.sni_reality_radio_button_label_yandex_24); - case SNI_REALITY_SAFARI_26_5 -> - getString(R.string.sni_reality_radio_button_label_safari_26_5); - case SNI_REALITY_SAFARI_26_4 -> - getString(R.string.sni_reality_radio_button_label_safari_26_4); - default -> mode.toString(); - }; - } -} diff --git a/app/src/main/java/org/fptn/vpn/views/bypassmethod/BypassMethodsActivity.kt b/app/src/main/java/org/fptn/vpn/views/bypassmethod/BypassMethodsActivity.kt new file mode 100644 index 00000000..6a44aae8 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/views/bypassmethod/BypassMethodsActivity.kt @@ -0,0 +1,118 @@ +package org.fptn.vpn.views.bypassmethod + +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.IBinder +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import com.elvishew.xlog.XLog +import com.google.common.util.concurrent.FutureCallback +import com.google.common.util.concurrent.Futures +import org.fptn.vpn.database.entity.ServerEntity +import org.fptn.vpn.services.snichecker.SniCheckerService +import org.fptn.vpn.services.snichecker.SniCheckerServiceState +import org.fptn.vpn.ui.screens.BypassMethodsScreen +import org.fptn.vpn.ui.theme.FptnTheme +import org.fptn.vpn.views.home.HomeActivity +import org.fptn.vpn.views.settings.SettingsActivity + +class BypassMethodsActivity : ComponentActivity() { + + private val tag = "BypassMethodsActivity" + private val viewModel: BypassMethodsViewModel by viewModels() + private var connection: ServiceConnection? = null + + override fun onStart() { + super.onStart() + connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + XLog.tag(tag).i("SNI checker service connected") + val binder = service as SniCheckerService.LocalBinder + viewModel.subscribeService(binder.service) + } + override fun onServiceDisconnected(name: ComponentName) { + viewModel.unsubscribe() + } + } + SniCheckerService.bindService(this, connection) + } + + override fun onStop() { + super.onStop() + try { connection?.let { unbindService(it) } } catch (e: Exception) { + XLog.tag(tag).e("Error unbinding: %s", e.message) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + try { viewModel.loadDefaultSni() } catch (e: Exception) { + XLog.tag(tag).w("Failed to load default SNI: %s", e.message) + } + + setContent { + val bypassMethod by viewModel.bypassCensorshipMethodMutableLiveData.observeAsState() + val sniMode by viewModel.sniSpoofingModeMutableLiveData.observeAsState() + val sniText by viewModel.sniMutableLiveData.observeAsState("") + val sniCount by viewModel.sniCountLiveData.observeAsState(0) + val serviceState by viewModel.serviceState.observeAsState(SniCheckerServiceState.INACTIVE) + val progress by viewModel.currentProgress.observeAsState(android.util.Pair.create(0, 1)) + val currentSni by viewModel.currentCheckingSniInfo.observeAsState("") + val selectedServer by viewModel.selectedServer.observeAsState(ServerEntity.AUTO) + val allServersState by viewModel.allServersCacheLiveData.observeAsState(emptyList()) + + FptnTheme { + BypassMethodsScreen( + bypassMethod = bypassMethod ?: viewModel.bypassCensorshipMethodMutableLiveData.value!!, + sniSpoofingMode = sniMode ?: viewModel.sniSpoofingModeMutableLiveData.value!!, + sniText = sniText ?: "", + sniCount = sniCount ?: 0, + serviceState = serviceState, + progress = Pair(progress?.first ?: 0, progress?.second ?: 1), + currentCheckingSni = currentSni ?: "", + selectedServer = selectedServer ?: ServerEntity.AUTO, + allServers = allServersState ?: emptyList(), + onBypassMethodChange = { viewModel.setBypassMethod(it) }, + onSniSpoofingModeChange = { viewModel.setSniSpoofingMode(it) }, + onEditSni = { viewModel.validateAndSetSni(it) }, + onSave = { + viewModel.saveBypassMethod() + finish() + }, + onCancel = { finish() }, + onStartAutoSelect = { server, reset -> + SniCheckerService.startChecking( + this, + server, + reset, + viewModel.bypassCensorshipMethodMutableLiveData.value + ) + }, + onStopAutoSelect = { SniCheckerService.stopChecking(this) }, + onHome = { startActivity(Intent(this, HomeActivity::class.java)) }, + onSettings = { startActivity(Intent(this, SettingsActivity::class.java)) }, + ) + } + } + + loadAllServers() + } + + private fun loadAllServers() { + Futures.addCallback(viewModel.getAllServers(), object : FutureCallback?> { + override fun onSuccess(result: List?) { + viewModel.allServersCacheLiveData.postValue(result ?: emptyList()) + } + override fun onFailure(t: Throwable) { + XLog.tag(tag).e("Failed to load servers: %s", t.message) + } + }, mainExecutor) + } +} 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 6b35a27b..2dfb655e 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 @@ -63,6 +63,10 @@ public class BypassMethodsViewModel extends AndroidViewModel { @Getter private final MutableLiveData foundedSniEvent = new MutableLiveData<>(); + // Cache for Compose UI - populated from Activity after async load + @Getter + public final MutableLiveData> allServersCacheLiveData = new MutableLiveData<>(List.of()); + private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); private final ExecutorService executorService = Executors.newSingleThreadExecutor(); diff --git a/app/src/main/java/org/fptn/vpn/views/experimentalsettings/ExperimentalSettingsActivity.java b/app/src/main/java/org/fptn/vpn/views/experimentalsettings/ExperimentalSettingsActivity.java deleted file mode 100644 index ff9ae615..00000000 --- a/app/src/main/java/org/fptn/vpn/views/experimentalsettings/ExperimentalSettingsActivity.java +++ /dev/null @@ -1,230 +0,0 @@ -package org.fptn.vpn.views.experimentalsettings; - -import android.annotation.SuppressLint; -import android.app.StatusBarManager; -import android.content.ComponentName; -import android.graphics.drawable.Icon; -import android.os.Build; -import android.os.Bundle; -import android.view.View; -import android.widget.Button; -import android.widget.SeekBar; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.SwitchCompat; - -import com.elvishew.xlog.XLog; - -import org.fptn.vpn.R; -import org.fptn.vpn.services.tile.FptnTileService; -import org.fptn.vpn.utils.SharedPrefUtils; - -public class ExperimentalSettingsActivity extends AppCompatActivity { - private final String TAG = this.getClass().getSimpleName(); - - private static final int[] ATTEMPTS_COUNT_VALUES = {5, 15, 35, Integer.MAX_VALUE}; - - private SwitchCompat switchNetworkType; - private SwitchCompat switchIPAddress; - private SeekBar seekBarAttemptsCount; - private SeekBar seekBarDelayBetween; - private SwitchCompat resetServerAfterDisconnectSwitch; - private SwitchCompat resetServerAfterDisconnectOnException; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.experimental_settings_layout); - - initializeVariable(); - } - - @SuppressLint("InlinedApi") - private void initializeVariable() { - switchNetworkType = findViewById(R.id.reconnect_on_change_network_type_switch); - switchNetworkType.setChecked(SharedPrefUtils.getReconnectOnChangeNetworkTypeEnabled(this)); - - switchIPAddress = findViewById(R.id.reconnect_on_change_ip_address_switch); - switchIPAddress.setChecked(SharedPrefUtils.getReconnectOnChangeIPEnabled(this)); - - // Reconnects attempts count - seekBarAttemptsCount = findViewById(R.id.seekBarAttemptsCount); - TextView textViewAttemptsCount = findViewById(R.id.textViewAttemptsCount); - seekBarAttemptsCount.setOnSeekBarChangeListener(new SimpleSeekBarChangeListener() { - @Override - public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - int value = ATTEMPTS_COUNT_VALUES[progress]; - if (value == Integer.MAX_VALUE) { - textViewAttemptsCount.setText("∞"); - } else { - String format = getString(R.string.reconnect_attempts_text); - textViewAttemptsCount.setText(String.format(format, value)); - } - } - }); - - int reconnectAttemptsCount = SharedPrefUtils.getReconnectAttemptsCount(this); - int progressToSet = 2; // default to 35 - for (int i = 0; i < ATTEMPTS_COUNT_VALUES.length; i++) { - if (ATTEMPTS_COUNT_VALUES[i] >= reconnectAttemptsCount) { - progressToSet = i; - break; - } - } - seekBarAttemptsCount.setProgress(progressToSet); - - // Reconnects delay between in seconds - seekBarDelayBetween = findViewById(R.id.seekBarDelayBetween); - TextView textViewDelayBetween = findViewById(R.id.textViewDelayBetween); - seekBarDelayBetween.setOnSeekBarChangeListener(new SimpleSeekBarChangeListener() { - @Override - public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - String format = getString(R.string.delay_between_attempts_seconds); - textViewDelayBetween.setText(String.format(format, progress + 1)); - } - }); - - int delayBetweenReconnect = SharedPrefUtils.getDelayBetweenReconnect(this); - seekBarDelayBetween.setProgress(0); - seekBarDelayBetween.setProgress(delayBetweenReconnect - 1); - - // Quick tile request - View tileButtonLayout = findViewById(R.id.tile_layout); - Button buttonRequestTile = findViewById(R.id.quick_settings_tile_button); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - buttonRequestTile.setVisibility(View.VISIBLE); - buttonRequestTile.setEnabled(true); - buttonRequestTile.setOnClickListener(v -> requestQuickSettingsTile(buttonRequestTile)); - - tileButtonLayout.setVisibility(View.VISIBLE); - } else { - buttonRequestTile.setVisibility(View.GONE); // Use GONE to free up layout space - - tileButtonLayout.setVisibility(View.GONE); - } - - // Reset selected server on disconnect - resetServerAfterDisconnectSwitch = findViewById(R.id.reset_selected_server_after_disconnect_switch); - resetServerAfterDisconnectSwitch.setChecked(SharedPrefUtils.getResetSelectedServerEnabled(this)); - - // Reset selected server on disconnect with exception - resetServerAfterDisconnectOnException = findViewById(R.id.reset_selected_server_after_disconnect_with_exception); - resetServerAfterDisconnectOnException.setChecked(SharedPrefUtils.getResetSelectedServerEnabled(this)); - - resetServerAfterDisconnectOnException.setChecked(SharedPrefUtils.getResetSelectedServerOnExceptionEnabled(this)); - updateExceptionVisibility(resetServerAfterDisconnectSwitch.isChecked()); - - // Toggle visibility on change - resetServerAfterDisconnectSwitch.setOnCheckedChangeListener( - (buttonView, isChecked) -> updateExceptionVisibility(isChecked)); - - - // Save and Cancel buttons - Button cancelButton = findViewById(R.id.cancel_button); - cancelButton.setOnClickListener(v -> { - XLog.tag(TAG).i("Experimental settings cancelled"); - finish(); - }); - - Button saveButton = findViewById(R.id.save_button); - saveButton.setOnClickListener(v -> saveAndFinish()); - } - - private void updateExceptionVisibility(boolean isVisible) { - resetServerAfterDisconnectOnException.setVisibility(isVisible ? View.GONE : View.VISIBLE); - } - - @RequiresApi(api = Build.VERSION_CODES.TIRAMISU) - private void requestQuickSettingsTile(Button buttonRequestTile) { - StatusBarManager statusBarManager = getSystemService(StatusBarManager.class); - if (statusBarManager == null) return; - - // Disable button to prevent double-clicks - buttonRequestTile.setEnabled(false); - - try { - ComponentName componentName = new ComponentName(this, FptnTileService.class); - String label = getString(R.string.app_name); // Or specific tile label - Icon icon = Icon.createWithResource(this, R.drawable.ic_logo); - statusBarManager.requestAddTileService( - componentName, - label, - icon, - getMainExecutor(), - resultCode -> handleTileRequestResult(resultCode, buttonRequestTile) - ); - } catch (Exception e) { - XLog.tag(TAG).e("Failed to request quick settings tile addition: %s", e.getMessage()); - Toast.makeText(this, R.string.tile_addition_failed, Toast.LENGTH_SHORT).show(); - buttonRequestTile.setEnabled(true); - } - } - - private void handleTileRequestResult(int resultCode, Button button) { - // Re-enable button unless the tile was successfully added/exists - boolean shouldKeepDisabled = false; - - switch (resultCode) { - case StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ALREADY_ADDED: - XLog.tag(TAG).i("Quick settings tile already present"); - Toast.makeText(this, R.string.tile_already_added, Toast.LENGTH_SHORT).show(); - button.setBackgroundResource(R.drawable.round_back_secondary_cancel_100); - shouldKeepDisabled = true; - break; - - case StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ADDED: - XLog.tag(TAG).i("Quick settings tile added successfully"); - Toast.makeText(this, R.string.tile_added_successfully, Toast.LENGTH_SHORT).show(); - shouldKeepDisabled = true; - break; - - case StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_NOT_ADDED: - XLog.tag(TAG).w("Quick settings tile request was declined"); - button.setEnabled(true); - break; - - default: - XLog.tag(TAG).w("Quick settings tile request returned unexpected result [code=%d]", resultCode); - button.setEnabled(true); - break; - } - - if (shouldKeepDisabled) { - button.setEnabled(false); - button.setAlpha(0.5f); // Visual cue that it's no longer needed - } - } - - private void saveAndFinish() { - XLog.tag(TAG).i("Experimental settings saved [watchNetwork=%b, watchIP=%b, attempts=%d, delay=%ds]", switchNetworkType.isChecked(), switchIPAddress.isChecked(), seekBarAttemptsCount.getProgress(), seekBarDelayBetween.getProgress() + 1); - - SharedPrefUtils.saveReconnectOnChangeNetworkTypeEnabled(this, switchNetworkType.isChecked()); - SharedPrefUtils.saveReconnectOnChangeIPEnabled(this, switchIPAddress.isChecked()); - SharedPrefUtils.saveResetSelectedServerEnabled(this, resetServerAfterDisconnectSwitch.isChecked()); - SharedPrefUtils.saveResetSelectedServerOnExceptionEnabled(this, resetServerAfterDisconnectOnException.isChecked()); - - int attemptsCountProgress = seekBarAttemptsCount.getProgress(); - SharedPrefUtils.saveReconnectAttemptsCount(this, ATTEMPTS_COUNT_VALUES[attemptsCountProgress]); - - int delayBetweenProgress = seekBarDelayBetween.getProgress(); - SharedPrefUtils.saveDelayBetweenReconnect(this, delayBetweenProgress + 1); - - finish(); - } - - // Helper to reduce boilerplate for SeekBars - private abstract static class SimpleSeekBarChangeListener implements SeekBar.OnSeekBarChangeListener { - @Override - public void onStartTrackingTouch(SeekBar seekBar) { - } - - @Override - public void onStopTrackingTouch(SeekBar seekBar) { - } - } -} - diff --git a/app/src/main/java/org/fptn/vpn/views/experimentalsettings/ExperimentalSettingsActivity.kt b/app/src/main/java/org/fptn/vpn/views/experimentalsettings/ExperimentalSettingsActivity.kt new file mode 100644 index 00000000..c43b321b --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/views/experimentalsettings/ExperimentalSettingsActivity.kt @@ -0,0 +1,97 @@ +package org.fptn.vpn.views.experimentalsettings + +import android.annotation.SuppressLint +import android.app.StatusBarManager +import android.content.ComponentName +import android.graphics.drawable.Icon +import android.os.Build +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.elvishew.xlog.XLog +import org.fptn.vpn.R +import org.fptn.vpn.services.tile.FptnTileService +import org.fptn.vpn.ui.screens.ExperimentalSettings +import org.fptn.vpn.ui.screens.ExperimentalSettingsScreen +import org.fptn.vpn.ui.theme.FptnTheme +import org.fptn.vpn.utils.SharedPrefUtils + +class ExperimentalSettingsActivity : ComponentActivity() { + + private val tag = "ExperimentalSettingsActivity" + private val attemptsValues = intArrayOf(5, 15, 35, Int.MAX_VALUE) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val reconnectAttempts = SharedPrefUtils.getReconnectAttemptsCount(this) + val attemptsProgress = attemptsValues.indexOfFirst { it >= reconnectAttempts }.takeIf { it >= 0 } ?: 2 + val delayProgress = SharedPrefUtils.getDelayBetweenReconnect(this) - 1 + + val initial = ExperimentalSettings( + reconnectOnNetworkChange = SharedPrefUtils.getReconnectOnChangeNetworkTypeEnabled(this), + reconnectOnIpChange = SharedPrefUtils.getReconnectOnChangeIPEnabled(this), + attemptsProgress = attemptsProgress, + delayProgress = delayProgress, + resetServerOnDisconnect = SharedPrefUtils.getResetSelectedServerEnabled(this), + resetServerOnException = SharedPrefUtils.getResetSelectedServerOnExceptionEnabled(this), + ) + + setContent { + FptnTheme { + ExperimentalSettingsScreen( + initial = initial, + showTileButton = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU, + onRequestTile = { requestQuickSettingsTile() }, + onSave = { settings -> saveAndFinish(settings) }, + onCancel = { finish() }, + ) + } + } + } + + private fun saveAndFinish(settings: ExperimentalSettings) { + SharedPrefUtils.saveReconnectOnChangeNetworkTypeEnabled(this, settings.reconnectOnNetworkChange) + SharedPrefUtils.saveReconnectOnChangeIPEnabled(this, settings.reconnectOnIpChange) + SharedPrefUtils.saveResetSelectedServerEnabled(this, settings.resetServerOnDisconnect) + SharedPrefUtils.saveResetSelectedServerOnExceptionEnabled(this, settings.resetServerOnException) + SharedPrefUtils.saveReconnectAttemptsCount(this, attemptsValues[settings.attemptsProgress]) + SharedPrefUtils.saveDelayBetweenReconnect(this, settings.delayProgress + 1) + + XLog.tag(tag).i( + "Experimental settings saved [watchNetwork=%b, watchIP=%b, attempts=%d, delay=%ds]", + settings.reconnectOnNetworkChange, + settings.reconnectOnIpChange, + attemptsValues[settings.attemptsProgress], + settings.delayProgress + 1, + ) + finish() + } + + @SuppressLint("InlinedApi") + private fun requestQuickSettingsTile() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + val statusBarManager = getSystemService(StatusBarManager::class.java) ?: return + try { + statusBarManager.requestAddTileService( + ComponentName(this, FptnTileService::class.java), + getString(R.string.app_name), + Icon.createWithResource(this, R.drawable.ic_logo), + mainExecutor + ) { resultCode -> + when (resultCode) { + StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ALREADY_ADDED -> + Toast.makeText(this, R.string.tile_already_added, Toast.LENGTH_SHORT).show() + StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ADDED -> + Toast.makeText(this, R.string.tile_added_successfully, Toast.LENGTH_SHORT).show() + else -> + XLog.tag(tag).w("Tile request result: %d", resultCode) + } + } + } catch (e: Exception) { + XLog.tag(tag).e("Failed to request tile: %s", e.message) + Toast.makeText(this, R.string.tile_addition_failed, Toast.LENGTH_SHORT).show() + } + } +} 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 deleted file mode 100644 index 77479710..00000000 --- a/app/src/main/java/org/fptn/vpn/views/home/HomeActivity.java +++ /dev/null @@ -1,467 +0,0 @@ -package org.fptn.vpn.views.home; - -import android.annotation.SuppressLint; -import android.app.AlertDialog; -import android.app.StatusBarManager; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.graphics.drawable.Icon; -import android.net.Uri; -import android.net.VpnService; -import android.os.Build; -import android.os.Bundle; -import android.os.IBinder; -import android.provider.Settings; -import android.view.View; -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.Lifecycle; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.ViewModelProvider; - -import com.elvishew.xlog.XLog; - -import org.fptn.vpn.R; -import org.fptn.vpn.database.entity.ServerEntity; -import org.fptn.vpn.enums.ConnectionState; -import org.fptn.vpn.services.vpn.FptnServiceState; -import org.fptn.vpn.services.tile.FptnTileService; -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; -import org.fptn.vpn.vpnclient.exception.ErrorCode; -import org.fptn.vpn.vpnclient.exception.PVNClientException; - -import com.google.android.material.bottomnavigation.BottomNavigationView; -import com.google.android.material.snackbar.Snackbar; - -import java.util.List; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicInteger; - -import lombok.Getter; - -public class HomeActivity extends AppCompatActivity { - private static final String TAG = HomeActivity.class.getSimpleName(); - - @Getter - private HomeActivityViewModel viewModel; - - private TextView connectionTimerTextView; - - private TextView downloadTextView; - private TextView uploadTextView; - - private TextView statusTextView; - private TextView errorTextView; - - private TextView connectedServerTextView; - - private View connectionTimeFrame; - private View serverInfoFrame; - private View homeSpeedFrame; - private View permissionWarningFrame; - - private CustomSpinner spinnerServers; - - private ToggleButton startStopButton; - - //for service binding - private ServiceConnection connection; - private BottomNavigationView bottomNavigationView; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.home_layout); - - initializeVariable(); - } - - @Override - protected void onStart() { - super.onStart(); - - connection = new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName name, IBinder service) { - XLog.tag(TAG).i("VPN service connected [component=%s]", name.getShortClassName()); - FptnService.LocalBinder localBinder = (FptnService.LocalBinder) service; - viewModel.subscribeService(localBinder.getService()); - } - - @Override - public void onServiceDisconnected(ComponentName name) { - try { - if (viewModel != null) { - viewModel.unsubscribe(); - } - } catch (Exception e) { - XLog.tag(TAG).e("Error handling VPN service disconnect: %s", e.getMessage()); - } - } - }; - FptnService.bindService(this, connection); - } - - @Override - protected void onStop() { - super.onStop(); - - try { - if (connection != null) { - unbindService(connection); - } - } catch (Exception e) { - XLog.tag(TAG).e("Error unbinding VPN service: %s", e.getMessage()); - } - } - - @SuppressLint("InlinedApi") - private void initializeVariable() { - ServerEntityAdapter serverEntityAdapter = new ServerEntityAdapter(R.layout.home_list_recycler_server_item); - spinnerServers = findViewById(R.id.home_server_spinner); - spinnerServers.setAdapter(serverEntityAdapter); - - startStopButton = findViewById(R.id.home_do_connect_button); - startStopButton.setOnClickListener(this::onClickToStartStop); - - /*View containers to hide*/ - homeSpeedFrame = findViewById(R.id.home_speed_frame); - connectionTimeFrame = findViewById(R.id.home_connection_timer_frame); - serverInfoFrame = findViewById(R.id.home_server_info_frame); - - viewModel = new ViewModelProvider(this).get(HomeActivityViewModel.class); - viewModel.getServerDtoListLiveData().observe(this, serverEntities -> { - ((ServerEntityAdapter) spinnerServers.getAdapter()).setServerEntityList(serverEntities); - - spinnerServers.performClosedEvent(); // FIX SPINNER BACKGROUND - updateSpinnerSelection(); - }); - - View settingsMenuItem = findViewById(R.id.menuSettings); - viewModel.getServiceStateMutableLiveData().observe(this, fptnServiceState -> { - // we can't change UI from viewModel - switch (fptnServiceState.getConnectionState()) { - case CONNECTED: - connectedStateUiItems(); - - viewModel.stopCheckingPing(); - break; - case DISCONNECTED: - disconnectedStateUiItems(); - updateSpinnerSelection(); - - // Check activity on foreground - if (getLifecycle().getCurrentState() == Lifecycle.State.RESUMED) { - viewModel.startCheckingPing(); - } - - break; - default: - break; - } - - boolean activeState = fptnServiceState.getConnectionState().isActiveState(); - startStopButton.setChecked(activeState); - spinnerServers.setEnabled(!activeState); - settingsMenuItem.setEnabled(!activeState); - - // we can't show Snackbar from viewModel - PVNClientException exception = fptnServiceState.getException(); - if (exception != null) { - if (ErrorCode.Companion.isNeedToOfferRefreshToken(exception.errorCode)) { - String errorText = Optional.ofNullable(viewModel.getErrorTextLiveData().getValue()) - .orElse(ErrorCode.UNKNOWN_ERROR.getValue()); - Snackbar snackbar = Snackbar.make(findViewById(R.id.layout), errorText, 8000); - if (ErrorCode.Companion.isNeedToOfferRefreshToken(exception.errorCode)) { - snackbar.setAction(getString(R.string.refresh_token), v -> { - Intent browserIntent = new - Intent(Intent.ACTION_VIEW, - Uri.parse(getString(R.string.telegram_bot_link))); - startActivity(browserIntent); - }); - } - snackbar.show(); - } - } - }); - - - downloadTextView = findViewById(R.id.home_download_speed); - viewModel.getDownloadSpeedAsStringLiveData().observe(this, downloadSpeed -> downloadTextView.setText(downloadSpeed)); - - uploadTextView = findViewById(R.id.home_upload_speed); - viewModel.getUploadSpeedAsStringLiveData().observe(this, uploadSpeed -> uploadTextView.setText(uploadSpeed)); - - connectionTimerTextView = findViewById(R.id.home_connection_timer); - viewModel.getTimerTextLiveData().observe(this, timerText -> connectionTimerTextView.setText(timerText)); - - errorTextView = findViewById(R.id.home_error_text_view); - viewModel.getErrorTextLiveData().observe(this, errorCodeText -> errorTextView.setText(errorCodeText)); - - statusTextView = findViewById(R.id.home_connection_status); - viewModel.getStatusTextLiveData().observe(this, statusText -> statusTextView.setText(statusText)); - - connectedServerTextView = findViewById(R.id.home_connected_server_name); - viewModel.getConnectedServerInfoLiveData().observe(this, serverInfoText -> connectedServerTextView.setText(serverInfoText)); - - bottomNavigationView = findViewById(R.id.bottomNavBar); - bottomNavigationView.setSelectedItemId(R.id.menuHome); - bottomNavigationView.setOnItemSelectedListener(new CustomBottomNavigationListener(this, R.id.menuHome)); - - permissionWarningFrame = findViewById(R.id.home_permission_warning_frame); - - // hide - disconnectedStateUiItems(); - - requestAddTileService(); - } - - private void updateSpinnerSelection() { - if (spinnerServers.getAdapter() != null - && spinnerServers.getAdapter() instanceof ServerEntityAdapter serverEntityAdapter) { - List serverEntityList = serverEntityAdapter.getServerEntityList(); - if (serverEntityList != null && !serverEntityList.isEmpty()) { - if (SharedPrefUtils.getResetSelectedServerEnabled(this)) { - spinnerServers.setSelection(0); - } else { - boolean selected = false; - for (int i = 0; i < serverEntityList.size(); i++) { - if (serverEntityList.get(i).isSelected()) { - spinnerServers.setSelection(i); - selected = true; - break; - } - } - if (!selected) { - spinnerServers.setSelection(0); - } - } - } - } - } - - // Activity on background - @Override - protected void onPause() { - super.onPause(); - - XLog.tag(TAG).d("Activity paused — ping checks suspended"); - - viewModel.stopCheckingPing(); - } - - // Activity on foreground - @Override - protected void onResume() { - super.onResume(); - - XLog.tag(TAG).d("Activity resumed"); - - if (!isFinishing() && !isDestroyed()) { - bottomNavigationView.setSelectedItemId(R.id.menuHome); - } - - Optional.ofNullable(viewModel.getServiceStateMutableLiveData()) - .map(LiveData::getValue) - .map(FptnServiceState::getConnectionState) - .ifPresent((state) -> { - if (state == ConnectionState.DISCONNECTED) { - viewModel.startCheckingPing(); - } - }); - } - - private void disconnectedStateUiItems() { - ViewUtils.hideView(connectionTimeFrame); - ViewUtils.hideView(serverInfoFrame); - ViewUtils.hideView(homeSpeedFrame); - ViewUtils.hideView(permissionWarningFrame); - - ViewUtils.showView(spinnerServers); - - if (getLifecycle().getCurrentState() == Lifecycle.State.RESUMED) { - viewModel.startCheckingPing(); - } else { - viewModel.stopCheckingPing(); - } - } - - private void connectedStateUiItems() { - ViewUtils.showView(connectionTimeFrame); - ViewUtils.showView(serverInfoFrame); - ViewUtils.showView(homeSpeedFrame); - - // check is need to show permissions warning - if (!PermissionsUtils.isAllOptionalPermissionsGranted(this)) { - ViewUtils.showView(permissionWarningFrame); - } - - ViewUtils.hideView(spinnerServers); - } - - public void onClickToStartStop(View v) { - ConnectionState currentConnectionState = Optional.ofNullable(viewModel.getServiceStateMutableLiveData().getValue()) - .map(FptnServiceState::getConnectionState) - .orElse(ConnectionState.DISCONNECTED); - if (currentConnectionState == ConnectionState.DISCONNECTED) { - - // Check notification enabled - if (!PermissionsUtils.checkNotificationEnabled(this)) { - Toast.makeText(this, R.string.notifications_request_title, Toast.LENGTH_SHORT) - .show(); - - Intent intent = new Intent(); - intent.setAction(Settings.ACTION_APP_NOTIFICATION_SETTINGS); - intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName()); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(intent); - - startStopButton.setChecked(false); - return; - } - - // Request required permission - boolean hasPermissionsRequestedBefore = SharedPrefUtils.isPermissionsRequested(this); - if (!hasPermissionsRequestedBefore) { - // we don't know result of vpn permission request yet - startStopButton.setChecked(false); - - requestRequiredPermissions(); - - // remember to not ask everytime - SharedPrefUtils.savePermissionsRequested(this, true); - - // we call onClick later - when receive all permissions request results - return; - } - - Intent intent = VpnService.prepare(this); - if (intent != null) { - // Request to user on launch vpn - vpnPermissionActivityResultLauncher.launch(intent); - // we don't know result of vpn permission request yet - startStopButton.setChecked(false); - } else { - // explicit assignment cause service may start slowly - viewModel.getServiceStateMutableLiveData().postValue(FptnServiceState.FAKE_CONNECTING); - - FptnService.startToConnect(this, (ServerEntity) spinnerServers.getSelectedItem()); - } - } else { - if (currentConnectionState.isActiveState()) { - FptnService.startToDisconnect(this); - } - } - } - - private void requestAddTileService() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (!SharedPrefUtils.isQuickSettingsTileRequested(this)) { - @SuppressLint("WrongConstant") StatusBarManager statusBarManager = (StatusBarManager) getSystemService(Context.STATUS_BAR_SERVICE); - try { - // Request to add a custom tile service - statusBarManager.requestAddTileService( - new ComponentName(this, FptnTileService.class), - "FPTN", - Icon.createWithResource(this, R.drawable.ic_logo), - this.getMainExecutor(), - (resultCode) -> { - if (resultCode == StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ALREADY_ADDED) { - XLog.tag(TAG).i("Quick settings tile already present — no action needed"); - Toast.makeText(this, R.string.tile_already_added, Toast.LENGTH_SHORT) - .show(); - } else if (resultCode == StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ADDED) { - XLog.tag(TAG).i("Quick settings tile added successfully"); - Toast.makeText(this, R.string.tile_added_successfully, Toast.LENGTH_SHORT) - .show(); - } else { - XLog.tag(TAG).i("User declined quick settings tile prompt [resultCode=%d]", resultCode); - } - } - ); - } catch (Exception e) { - XLog.tag(TAG).e("Failed to request quick settings tile addition: %s", e.getMessage()); - Toast.makeText(this, R.string.tile_addition_failed, Toast.LENGTH_SHORT) - .show(); - } - - SharedPrefUtils.saveQuickSettingsTileRequested(this, true); - } - } - } - - private final ActivityResultLauncher vpnPermissionActivityResultLauncher = registerForActivityResult( - new ActivityResultContracts.StartActivityForResult(), activityResult -> { - if (activityResult != null && activityResult.getResultCode() == RESULT_OK) { - FptnService.startToConnect(this, (ServerEntity) spinnerServers.getSelectedItem()); - } else { - Toast.makeText(this, R.string.vpn_permission_warning, Toast.LENGTH_SHORT).show(); - viewModel.getErrorTextLiveData().postValue(getString(R.string.vpn_permission_warning)); - } - } - ); - - private final AtomicInteger requestedPermissions = new AtomicInteger(0); - - private final ActivityResultLauncher settingsPermissionActivityResultLauncher = registerForActivityResult( - new ActivityResultContracts.StartActivityForResult(), - activityResult -> { - if (activityResult != null && activityResult.getResultCode() == RESULT_OK) { - XLog.tag(TAG).i("System permission granted via Settings"); - } else { - XLog.tag(TAG).w("System permission denied via Settings"); - } - if (requestedPermissions.decrementAndGet() == 0) { - startStopButton.callOnClick(); - } - } - ); - - /* PERMISSIONS PART */ - @SuppressLint("BatteryLife") - private void requestRequiredPermissions() { - new AlertDialog.Builder(this) - .setTitle(getString(R.string.permission_request_title)) - .setMessage(getString(R.string.permission_request_text)) - .setPositiveButton(getString(R.string.grant), (d, w) -> { - // Battery optimization permission - if (!PermissionsUtils.checkBatteryOptimizations(this)) { - //Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - requestedPermissions.incrementAndGet(); - startActivityWithSettings(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); - } - // Background data transfer restriction permission - if (!PermissionsUtils.checkBackgroundDataTransferRestrictions(this)) { - requestedPermissions.incrementAndGet(); - startActivityWithSettings(Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS); - } - }) - .setNegativeButton(getString(R.string.deny), (dialog, which) -> { - XLog.tag(TAG).w("Optional permissions denied by user — continuing without them"); - // it must work without permissions - startStopButton.callOnClick(); - }) - .show(); - } - - private void startActivityWithSettings(String settingsAction) { - Intent intent = new Intent(settingsAction); - intent.setData(Uri.parse("package:" + getPackageName())); - settingsPermissionActivityResultLauncher.launch(intent); - } - -} diff --git a/app/src/main/java/org/fptn/vpn/views/home/HomeActivity.kt b/app/src/main/java/org/fptn/vpn/views/home/HomeActivity.kt new file mode 100644 index 00000000..01ba1ded --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/views/home/HomeActivity.kt @@ -0,0 +1,273 @@ +package org.fptn.vpn.views.home + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.app.StatusBarManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.graphics.drawable.Icon +import android.net.Uri +import android.net.VpnService +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.provider.Settings +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.lifecycle.Lifecycle +import com.elvishew.xlog.XLog +import com.google.android.material.snackbar.Snackbar +import org.fptn.vpn.R +import org.fptn.vpn.database.entity.ServerEntity +import org.fptn.vpn.enums.ConnectionState +import org.fptn.vpn.services.tile.FptnTileService +import org.fptn.vpn.services.vpn.FptnService +import org.fptn.vpn.services.vpn.FptnServiceState +import org.fptn.vpn.ui.screens.HomeScreen +import org.fptn.vpn.ui.theme.FptnTheme +import org.fptn.vpn.utils.PermissionsUtils +import org.fptn.vpn.utils.SharedPrefUtils +import org.fptn.vpn.views.settings.SettingsActivity +import org.fptn.vpn.vpnclient.exception.ErrorCode +import java.util.Optional +import java.util.concurrent.atomic.AtomicInteger + +class HomeActivity : ComponentActivity() { + + private val tag = "HomeActivity" + private val viewModel: HomeActivityViewModel by viewModels() + private var serviceConn: ServiceConnection? = null + private var lastSelectedServer: ServerEntity? = null + + private val vpnPermissionLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result?.resultCode == RESULT_OK) { + lastSelectedServer?.let { FptnService.startToConnect(this, it) } + } else { + Toast.makeText(this, R.string.vpn_permission_warning, Toast.LENGTH_SHORT).show() + viewModel.errorTextLiveData.postValue(getString(R.string.vpn_permission_warning)) + } + } + + private val requestedPermissions = AtomicInteger(0) + + private val settingsPermLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + if (requestedPermissions.decrementAndGet() == 0) { + doConnect() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val serviceState by viewModel.serviceStateMutableLiveData.observeAsState(FptnServiceState.INITIAL) + val serverList by viewModel.serverDtoListLiveData.observeAsState(emptyList()) + val timerText by viewModel.timerTextLiveData.observeAsState(getString(R.string.zero_time)) + val downloadSpeed by viewModel.downloadSpeedAsStringLiveData.observeAsState(getString(R.string.zero_speed)) + val uploadSpeed by viewModel.uploadSpeedAsStringLiveData.observeAsState(getString(R.string.zero_speed)) + val errorText by viewModel.errorTextLiveData.observeAsState("") + val statusText by viewModel.statusTextLiveData.observeAsState(getString(R.string.disconnected)) + val connectedServerInfo by viewModel.connectedServerInfoLiveData.observeAsState("") + + val connectionState = serviceState?.connectionState ?: ConnectionState.DISCONNECTED + val isConnected = connectionState == ConnectionState.CONNECTED + val showPermissionWarning = isConnected && !PermissionsUtils.isAllOptionalPermissionsGranted(this) + + val currentSelectedServer = resolveSelectedServer(serverList ?: emptyList()) + + FptnTheme { + HomeScreen( + serverList = serverList ?: emptyList(), + selectedServer = currentSelectedServer, + connectionState = connectionState, + statusText = statusText ?: "", + errorText = errorText ?: "", + timerText = timerText ?: "", + downloadSpeed = downloadSpeed ?: "", + uploadSpeed = uploadSpeed ?: "", + connectedServerInfo = connectedServerInfo ?: "", + showPermissionWarning = showPermissionWarning, + settingsEnabled = !connectionState.isActiveState(), + onServerSelected = { server -> + lastSelectedServer = server + markServerSelected(server, serverList ?: emptyList()) + }, + onConnectDisconnect = { + val state = serviceState?.connectionState ?: ConnectionState.DISCONNECTED + if (state == ConnectionState.DISCONNECTED) { + lastSelectedServer = currentSelectedServer + onClickConnect() + } else if (state.isActiveState()) { + FptnService.startToDisconnect(this) + } + }, + onHome = { /* already here */ }, + onSettings = { + startActivity(Intent(this, SettingsActivity::class.java)) + }, + ) + } + } + + // observe exception for snackbar (must run on Activity, not Compose) + viewModel.serviceStateMutableLiveData.observe(this) { state -> + handleException(state) + } + + requestAddTileService() + } + + override fun onStart() { + super.onStart() + serviceConn = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + XLog.tag(tag).i("VPN service connected") + val binder = service as FptnService.LocalBinder + viewModel.subscribeService(binder.service) + } + override fun onServiceDisconnected(name: ComponentName) { + viewModel.unsubscribe() + } + } + FptnService.bindService(this, serviceConn) + } + + override fun onStop() { + super.onStop() + try { serviceConn?.let { unbindService(it) } } catch (e: Exception) { + XLog.tag(tag).e("Error unbinding VPN service: %s", e.message) + } + } + + override fun onPause() { + super.onPause() + viewModel.stopCheckingPing() + } + + override fun onResume() { + super.onResume() + val state = viewModel.serviceStateMutableLiveData.value?.connectionState + if (state == ConnectionState.DISCONNECTED) { + viewModel.startCheckingPing() + } + } + + private fun resolveSelectedServer(servers: List): ServerEntity? { + if (servers.isEmpty()) return null + if (SharedPrefUtils.getResetSelectedServerEnabled(this)) return servers.first() + return servers.firstOrNull { it.isSelected } ?: servers.first() + } + + private fun markServerSelected(server: ServerEntity, all: List) { + all.forEach { it.isSelected = it.id == server.id } + } + + private fun onClickConnect() { + if (!PermissionsUtils.checkNotificationEnabled(this)) { + Toast.makeText(this, R.string.notifications_request_title, Toast.LENGTH_SHORT).show() + startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }) + return + } + + if (!SharedPrefUtils.isPermissionsRequested(this)) { + SharedPrefUtils.savePermissionsRequested(this, true) + requestRequiredPermissions() + return + } + + doConnect() + } + + private fun doConnect() { + val intent = VpnService.prepare(this) + if (intent != null) { + vpnPermissionLauncher.launch(intent) + } else { + viewModel.serviceStateMutableLiveData.postValue(FptnServiceState.FAKE_CONNECTING) + FptnService.startToConnect(this, lastSelectedServer ?: resolveSelectedServer(viewModel.serverDtoListLiveData.value ?: emptyList())) + } + } + + @SuppressLint("BatteryLife") + private fun requestRequiredPermissions() { + AlertDialog.Builder(this) + .setTitle(getString(R.string.permission_request_title)) + .setMessage(getString(R.string.permission_request_text)) + .setPositiveButton(getString(R.string.grant)) { _, _ -> + if (!PermissionsUtils.checkBatteryOptimizations(this)) { + requestedPermissions.incrementAndGet() + startActivityWithSettings(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + } + if (!PermissionsUtils.checkBackgroundDataTransferRestrictions(this)) { + requestedPermissions.incrementAndGet() + startActivityWithSettings(Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS) + } + if (requestedPermissions.get() == 0) doConnect() + } + .setNegativeButton(getString(R.string.deny)) { _, _ -> doConnect() } + .show() + } + + private fun startActivityWithSettings(action: String) { + settingsPermLauncher.launch(Intent(action).apply { + data = Uri.parse("package:$packageName") + }) + } + + private fun handleException(state: FptnServiceState?) { + val exception = state?.exception ?: return + if (!ErrorCode.isNeedToOfferRefreshToken(exception.errorCode)) return + + val errorText = viewModel.errorTextLiveData.value ?: ErrorCode.UNKNOWN_ERROR.value + // Snackbar needs a View - use window decorView + val rootView = window.decorView.rootView + val snackbar = Snackbar.make(rootView, errorText, 8000) + if (ErrorCode.isNeedToOfferRefreshToken(exception.errorCode)) { + snackbar.setAction(getString(R.string.refresh_token)) { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.telegram_bot_link)))) + } + } + snackbar.show() + } + + @SuppressLint("WrongConstant", "InlinedApi") + private fun requestAddTileService() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + if (SharedPrefUtils.isQuickSettingsTileRequested(this)) return + + val statusBarManager = getSystemService(Context.STATUS_BAR_SERVICE) as StatusBarManager + try { + statusBarManager.requestAddTileService( + ComponentName(this, FptnTileService::class.java), + "FPTN", + Icon.createWithResource(this, R.drawable.ic_logo), + mainExecutor + ) { resultCode -> + when (resultCode) { + StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ALREADY_ADDED -> + Toast.makeText(this, R.string.tile_already_added, Toast.LENGTH_SHORT).show() + StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ADDED -> + Toast.makeText(this, R.string.tile_added_successfully, Toast.LENGTH_SHORT).show() + } + } + } catch (e: Exception) { + XLog.tag(tag).e("Failed to request tile: %s", e.message) + Toast.makeText(this, R.string.tile_addition_failed, Toast.LENGTH_SHORT).show() + } + SharedPrefUtils.saveQuickSettingsTileRequested(this, true) + } +} diff --git a/app/src/main/java/org/fptn/vpn/views/log/LogsActivity.java b/app/src/main/java/org/fptn/vpn/views/log/LogsActivity.java deleted file mode 100644 index 204a0ecd..00000000 --- a/app/src/main/java/org/fptn/vpn/views/log/LogsActivity.java +++ /dev/null @@ -1,197 +0,0 @@ -package org.fptn.vpn.views.log; - -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.os.Bundle; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.appcompat.app.AppCompatActivity; - -import com.google.android.material.bottomnavigation.BottomNavigationView; - -import org.fptn.vpn.R; -import org.fptn.vpn.views.CustomBottomNavigationListener; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.util.Arrays; - -public class LogsActivity extends AppCompatActivity { - - private TextView tvLogs; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.logs_layout); - - tvLogs = findViewById(R.id.tv_logs); - tvLogs.setText(loadLogs()); - tvLogs.setOnClickListener(v -> copyLogs()); - - BottomNavigationView bottomNavigationView = findViewById(R.id.bottomNavBar); - bottomNavigationView.setSelectedItemId(R.id.menuSettings); - bottomNavigationView.setOnItemSelectedListener( - new CustomBottomNavigationListener(this, R.id.menuSettings)); - } - - private void copyLogs() { - String text = tvLogs.getText().toString(); - if (text == null || text.isEmpty()) { - Toast.makeText(this, R.string.logs_empty, Toast.LENGTH_SHORT).show(); - return; - } - ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setPrimaryClip(ClipData.newPlainText("logs", text)); - Toast.makeText(this, R.string.logs_copied, Toast.LENGTH_SHORT).show(); - } - - private String loadLogs() { - File logDir = new File(getFilesDir(), "logs2"); - if (!logDir.exists() || !logDir.isDirectory()) { - return "No logs directory."; - } - File[] files = logDir.listFiles(); - if (files == null || files.length == 0) { - return "No log files."; - } - File latest = Arrays.stream(files) - .filter(f -> f.isFile() && f.canRead()) - .max((a, b) -> Long.compare(a.lastModified(), b.lastModified())) - .orElse(null); - if (latest == null) { - return "No readable log file."; - } - StringBuilder sb = new StringBuilder(); - try (BufferedReader br = new BufferedReader(new FileReader(latest))) { - String line; - while ((line = br.readLine()) != null) { - sb.append(line).append("\n"); - } - } catch (Exception e) { - return "Error reading log: " + e.getMessage(); - } - return sb.length() == 0 ? "Log file is empty." : sb.toString(); - } -} - -//package org.fptn.vpn.views.log; -// -//import android.content.ClipData; -//import android.content.ClipboardManager; -//import android.content.Context; -//import android.os.Bundle; -//import android.widget.TextView; -//import android.widget.Toast; -// -//import androidx.appcompat.app.AppCompatActivity; -// -//import com.google.android.material.bottomnavigation.BottomNavigationView; -// -//import org.fptn.vpn.R; -//import org.fptn.vpn.views.CustomBottomNavigationListener; -// -//import java.io.File; -//import java.io.FileReader; -//import java.io.BufferedReader; -//import java.util.Arrays; -// -//public class LogsActivity extends AppCompatActivity { -// -// private TextView tvLogs; -// -// @Override -// protected void onCreate(Bundle savedInstanceState) { -// super.onCreate(savedInstanceState); -// setContentView(R.layout.logs_layout); -// -// tvLogs = findViewById(R.id.tv_logs); -// -// String logs = loadLogs(); -// -// tvLogs.setText(logs); -// -// tvLogs.setOnLongClickListener(v -> { -// copyLogs(); -// return true; -// }); -// -// BottomNavigationView bottomNavigationView = findViewById(R.id.bottomNavBar); -// bottomNavigationView.setSelectedItemId(R.id.menuSettings); -// bottomNavigationView.setOnItemSelectedListener(new CustomBottomNavigationListener(this, R.id.menuSettings)); -// } -// -// public String loadLogs() { -// File logDir = new File(getFilesDir(), "logs2"); -// -// if (!logDir.exists()) { -// return "Log directory does not exist:\n" + logDir.getAbsolutePath() + -// "\n\nPlease run the app and trigger some XLog.i() calls."; -// } -// if (!logDir.isDirectory()) { -// return "Path exists but is not a directory:\n" + logDir.getAbsolutePath(); -// } -// -// File[] files = logDir.listFiles(); -// if (files == null || files.length == 0) { -// return "No files in:\n" + logDir.getAbsolutePath() + -// "\n\nCheck if XLog was initialized and write permission is OK."; -// } -// -// StringBuilder report = new StringBuilder(); -// report.append("Directory: ").append(logDir.getAbsolutePath()).append("\n"); -// report.append("Files count: ").append(files.length).append("\n"); -// for (File f : files) { -// report.append("• ").append(f.getName()) -// .append(" (").append(f.length()).append(" bytes") -// .append(f.canRead() ? ", readable" : ", NOT readable") -// .append(")\n"); -// } -// -// File latestFile = null; -// long latestTime = 0; -// for (File f : files) { -// if (f.isFile() && f.canRead()) { -// long mod = f.lastModified(); -// if (mod > latestTime) { -// latestTime = mod; -// latestFile = f; -// } -// } -// } -// -// if (latestFile == null) { -// return report.toString() + "\nNo readable log files found."; -// } -// -// StringBuilder content = new StringBuilder(); -// content.append(report).append("File: ").append(latestFile.getName()).append("\n"); -// content.append("Last modified: ").append(new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault()).format(new java.util.Date(latestTime))).append("\n"); -// -// try (BufferedReader reader = new BufferedReader(new FileReader(latestFile))) { -// String line; -// int lineCount = 0; -// while ((line = reader.readLine()) != null) { -// content.append(line).append("\n"); -// lineCount++; -// } -// if (lineCount == 0) { -// content.append("[File is empty]\n"); -// } -// } catch (Exception e) { -// content.append("Error reading file: ").append(e.getMessage()).append("\n"); -// } -// -// return content.toString(); -// } -// -// private void copyLogs() { -// ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); -// ClipData clip = ClipData.newPlainText("app_logs", tvLogs.getText().toString()); -// clipboard.setPrimaryClip(clip); -// Toast.makeText(this, "Copied", Toast.LENGTH_SHORT).show(); -// } -//} diff --git a/app/src/main/java/org/fptn/vpn/views/log/LogsActivity.kt b/app/src/main/java/org/fptn/vpn/views/log/LogsActivity.kt new file mode 100644 index 00000000..048ee032 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/views/log/LogsActivity.kt @@ -0,0 +1,76 @@ +package org.fptn.vpn.views.log + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import org.fptn.vpn.R +import org.fptn.vpn.ui.screens.LogsScreen +import org.fptn.vpn.ui.theme.FptnTheme +import org.fptn.vpn.views.home.HomeActivity +import org.fptn.vpn.views.settings.SettingsActivity +import java.io.BufferedReader +import java.io.File +import java.io.FileReader +import java.util.Arrays + +class LogsActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val logs = loadLogs() + setContent { + FptnTheme { + LogsScreen( + logsText = logs, + onCopyLogs = { copyLogs(logs) }, + onHome = { + startActivity(Intent(this, HomeActivity::class.java)) + }, + onSettings = { + startActivity(Intent(this, SettingsActivity::class.java)) + }, + ) + } + } + } + + private fun copyLogs(text: String) { + if (text.isEmpty()) { + Toast.makeText(this, R.string.logs_empty, Toast.LENGTH_SHORT).show() + return + } + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("logs", text)) + Toast.makeText(this, R.string.logs_copied, Toast.LENGTH_SHORT).show() + } + + private fun loadLogs(): String { + val logDir = File(filesDir, "logs2") + if (!logDir.exists() || !logDir.isDirectory) return "No logs directory." + val files = logDir.listFiles() ?: return "No log files." + if (files.isEmpty()) return "No log files." + + val latest = files + .filter { it.isFile && it.canRead() } + .maxByOrNull { it.lastModified() } + ?: return "No readable log file." + + return try { + val sb = StringBuilder() + BufferedReader(FileReader(latest)).use { br -> + var line: String? + while (br.readLine().also { line = it } != null) { + sb.append(line).append("\n") + } + } + if (sb.isEmpty()) "Log file is empty." else sb.toString() + } catch (e: Exception) { + "Error reading log: ${e.message}" + } + } +} diff --git a/app/src/main/java/org/fptn/vpn/views/login/LoginActivity.java b/app/src/main/java/org/fptn/vpn/views/login/LoginActivity.java deleted file mode 100644 index e6e9e782..00000000 --- a/app/src/main/java/org/fptn/vpn/views/login/LoginActivity.java +++ /dev/null @@ -1,113 +0,0 @@ -package org.fptn.vpn.views.login; - -import android.annotation.SuppressLint; -import android.content.Intent; -import android.os.Bundle; -import android.text.Html; -import android.text.method.LinkMovementMethod; -import android.view.MotionEvent; -import android.view.WindowManager; -import android.widget.Button; -import android.widget.EditText; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.activity.OnBackPressedCallback; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.lifecycle.ViewModelProvider; - -import com.elvishew.xlog.XLog; -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.views.home.HomeActivity; - -public class LoginActivity extends AppCompatActivity { - private static final String TAG = LoginActivity.class.getSimpleName(); - - private LoginActivityViewModel viewModel; - private EditText linkEditText; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.login_layout); - - initializeVariable(); - } - - @SuppressLint({"InlinedApi", "ClickableViewAccessibility"}) - private void initializeVariable() { - viewModel = new ViewModelProvider(this).get(LoginActivityViewModel.class); - - // Show HTML - TextView label = findViewById(R.id.fptn_login_html_label); - label.setText(Html.fromHtml(getString(R.string.telegram_bot_html), Html.FROM_HTML_MODE_LEGACY)); - label.setMovementMethod(LinkMovementMethod.getInstance()); - - TextView errorTextView = findViewById(R.id.errorTextView); - viewModel.getErrorTextLiveData().observe(this, errorTextView::setText); - - linkEditText = findViewById(R.id.fptn_login_link_input); - - Button loginButton = findViewById(R.id.fptn_login_button); - loginButton.setOnClickListener((v) -> onLogin()); - - // hide keyboard - linkEditText.setTextIsSelectable(true); - linkEditText.setShowSoftInputOnFocus(false); - linkEditText.setOnTouchListener((view, motionEvent) -> { - if (motionEvent.getAction() == MotionEvent.ACTION_UP) { - if (motionEvent.getX() > (view.getWidth() - view.getPaddingRight() - 50)) { - ((EditText) view).setText(""); - } - } - return false; - }); - getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN); // This just hide keyboard when activity starts - - // miss back button - getOnBackPressedDispatcher().addCallback(this, new MyOnBackPressedCallback()); - } - - public void onLogin() { - final String tokenLink = linkEditText.getText().toString(); - try { - ListenableFuture updateResult = viewModel.parseAndSaveToken(tokenLink); - Futures.addCallback(updateResult, new FutureCallback<>() { - @Override - public void onSuccess(Void result) { - Intent intent = new Intent(LoginActivity.this, HomeActivity.class); - startActivity(intent); - - finish(); - } - - @Override - public void onFailure(Throwable t) { - XLog.tag(TAG).e("Login failed: %s", t.getMessage()); - Toast.makeText(getApplicationContext(), t.getMessage(), Toast.LENGTH_SHORT).show(); - viewModel.getErrorTextLiveData().postValue(t.getMessage()); - } - }, getMainExecutor()); - } catch (Exception e) { - XLog.tag(TAG).e("Token parsing failed at login: %s", e.getMessage()); - Toast.makeText(getApplicationContext(), R.string.token_saving_failed, Toast.LENGTH_SHORT).show(); - viewModel.getErrorTextLiveData().postValue(getString(R.string.token_saving_failed)); - } - } - - private static class MyOnBackPressedCallback extends OnBackPressedCallback { - public MyOnBackPressedCallback() { - super(true); - } - - @Override - public void handleOnBackPressed() { - - } - } -} diff --git a/app/src/main/java/org/fptn/vpn/views/login/LoginActivity.kt b/app/src/main/java/org/fptn/vpn/views/login/LoginActivity.kt new file mode 100644 index 00000000..ff785514 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/views/login/LoginActivity.kt @@ -0,0 +1,62 @@ +package org.fptn.vpn.views.login + +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.addCallback +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import com.elvishew.xlog.XLog +import com.google.common.util.concurrent.FutureCallback +import com.google.common.util.concurrent.Futures +import org.fptn.vpn.R +import org.fptn.vpn.ui.screens.LoginScreen +import org.fptn.vpn.ui.theme.FptnTheme +import org.fptn.vpn.views.home.HomeActivity + +class LoginActivity : ComponentActivity() { + + private val viewModel: LoginActivityViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + onBackPressedDispatcher.addCallback(this) { /* disable back */ } + + setContent { + val errorText by viewModel.errorTextLiveData.observeAsState("") + FptnTheme { + LoginScreen( + errorText = errorText ?: "", + htmlLabel = getString(R.string.telegram_bot_html), + onLogin = ::onLogin, + ) + } + } + } + + private fun onLogin(tokenLink: String) { + try { + val future = viewModel.parseAndSaveToken(tokenLink) + Futures.addCallback(future, object : FutureCallback { + override fun onSuccess(result: Void?) { + startActivity(Intent(this@LoginActivity, HomeActivity::class.java)) + finish() + } + + override fun onFailure(t: Throwable) { + XLog.tag("LoginActivity").e("Login failed: %s", t.message) + Toast.makeText(applicationContext, t.message, Toast.LENGTH_SHORT).show() + viewModel.errorTextLiveData.postValue(t.message) + } + }, mainExecutor) + } catch (e: Exception) { + XLog.tag("LoginActivity").e("Token parsing failed: %s", e.message) + Toast.makeText(applicationContext, R.string.token_saving_failed, Toast.LENGTH_SHORT).show() + viewModel.errorTextLiveData.postValue(getString(R.string.token_saving_failed)) + } + } +} diff --git a/app/src/main/java/org/fptn/vpn/views/perappvpn/PerAppVpnModeActivity.java b/app/src/main/java/org/fptn/vpn/views/perappvpn/PerAppVpnModeActivity.java deleted file mode 100644 index 62cc3b7a..00000000 --- a/app/src/main/java/org/fptn/vpn/views/perappvpn/PerAppVpnModeActivity.java +++ /dev/null @@ -1,150 +0,0 @@ -package org.fptn.vpn.views.perappvpn; - -import static org.fptn.vpn.utils.ViewUtils.hideView; -import static org.fptn.vpn.utils.ViewUtils.showView; - -import android.os.Bundle; -import android.text.Editable; -import android.text.TextWatcher; -import android.view.View; -import android.widget.Button; -import android.widget.EditText; -import android.widget.ProgressBar; -import android.widget.RadioButton; -import android.widget.RadioGroup; - -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.SwitchCompat; -import androidx.lifecycle.ViewModelProvider; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.elvishew.xlog.XLog; -import com.google.android.material.bottomnavigation.BottomNavigationView; - -import org.fptn.vpn.R; -import org.fptn.vpn.enums.PerAppVpnMode; -import org.fptn.vpn.views.CustomBottomNavigationListener; - -public class PerAppVpnModeActivity extends AppCompatActivity { - private final String TAG = this.getClass().getSimpleName(); - - private PerAppVpnModeViewModel viewModel; - - private View selectAppsListLayout; - private RecyclerView appListRecyclerView; - private EditText searchAppsEditText; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.settings_per_app_vpnmode_layout); - - initializeVariable(); - } - - private void initializeVariable() { - viewModel = new ViewModelProvider(this).get(PerAppVpnModeViewModel.class); - - BottomNavigationView bottomNavigationView = findViewById(R.id.bottomNavBar); - bottomNavigationView.setSelectedItemId(R.id.menuSettings); - bottomNavigationView.setOnItemSelectedListener(new CustomBottomNavigationListener(this, R.id.menuSettings)); - - selectAppsListLayout = findViewById(R.id.select_apps_list_layout); - - searchAppsEditText = findViewById(R.id.search_apps_edit_text); - searchAppsEditText.addTextChangedListener(new TextWatcher() { - @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} - @Override public void onTextChanged(CharSequence s, int start, int before, int count) {} - @Override - public void afterTextChanged(Editable s) { - if (appListRecyclerView.getAdapter() instanceof AppInfoListAdapter adapter) { - adapter.filter(s.toString()); - } - } - }); - - SwitchCompat showSystemAppsSwitch = findViewById(R.id.show_system_apps_switch); - showSystemAppsSwitch.setOnCheckedChangeListener((btn, isChecked) -> - viewModel.setShowSystemApps(isChecked)); - - // Setup RadioGroup listener - RadioGroup protocolRadioGroup = findViewById(R.id.per_app_vpn_mode_radio_button_group); - protocolRadioGroup.setOnCheckedChangeListener((group, checkedId) -> { - if (checkedId == R.id.all_app_mode_radio_button) { - XLog.tag(TAG).i("Per-app VPN mode changed [mode=OFF]"); - viewModel.setPerAppVpnMode(PerAppVpnMode.OFF); - } else if (checkedId == R.id.allowed_apps_mode_radio_button) { - XLog.tag(TAG).i("Per-app VPN mode changed [mode=ONLY_ALLOWED]"); - viewModel.setPerAppVpnMode(PerAppVpnMode.ONLY_ALLOWED); - } else if (checkedId == R.id.disallowed_apps_mode_radio_button) { - XLog.tag(TAG).i("Per-app VPN mode changed [mode=EXCEPT_DISALLOWED]"); - viewModel.setPerAppVpnMode(PerAppVpnMode.EXCEPT_DISALLOWED); - } - }); - - RadioButton perAppVpnModeOffRadioButton = findViewById(R.id.all_app_mode_radio_button); - RadioButton onlyAllowedAppsRadioButton = findViewById(R.id.allowed_apps_mode_radio_button); - RadioButton exceptDisAllowedRadioButton = findViewById(R.id.disallowed_apps_mode_radio_button); - viewModel.getPerAppVpnModeMutableLiveData().observe(this, perAppVpnMode -> { - switch (perAppVpnMode) { - case OFF: - perAppVpnModeOffRadioButton.setChecked(true); - hideView(selectAppsListLayout); - break; - case ONLY_ALLOWED: - onlyAllowedAppsRadioButton.setChecked(true); - showView(selectAppsListLayout); - - if (appListRecyclerView.getAdapter() instanceof AppInfoListAdapter appInfoListAdapter) { - appInfoListAdapter.setPerAppVpnMode(PerAppVpnMode.ONLY_ALLOWED); - } - - break; - case EXCEPT_DISALLOWED: - exceptDisAllowedRadioButton.setChecked(true); - showView(selectAppsListLayout); - - if (appListRecyclerView.getAdapter() instanceof AppInfoListAdapter appInfoListAdapter) { - appInfoListAdapter.setPerAppVpnMode(PerAppVpnMode.EXCEPT_DISALLOWED); - } - - break; - } - }); - - appListRecyclerView = findViewById(R.id.apps_recycler_view); - appListRecyclerView.setLayoutManager(new LinearLayoutManager(this)); - - // Show a progress bar while loading if you have one in your XML - ProgressBar progressBar = findViewById(R.id.loading_apps_progress_bar); - - viewModel.getAppListMutableLiveData().observe(this, apps -> { - AppInfoListAdapter adapter = new AppInfoListAdapter(apps, viewModel.getPerAppVpnModeMutableLiveData().getValue()); - String query = searchAppsEditText.getText().toString(); - if (!query.isEmpty()) adapter.filter(query); - appListRecyclerView.setAdapter(adapter); - - hideView(progressBar); - }); - - // Trigger the load - viewModel.loadInstalledApps(getPackageManager()); - - // Save and Cancel buttons - Button cancelButton = findViewById(R.id.cancel_button); - cancelButton.setOnClickListener(v -> { - XLog.tag(TAG).i("Per-app VPN mode changes cancelled"); - finish(); - }); - - Button saveButton = findViewById(R.id.save_button); - saveButton.setOnClickListener(v -> { - XLog.tag(TAG).i("Per-app VPN mode saved [mode=%s]", viewModel.getPerAppVpnModeMutableLiveData().getValue()); - - viewModel.saveAllSettings(); - - finish(); - }); - } -} \ No newline at end of file diff --git a/app/src/main/java/org/fptn/vpn/views/perappvpn/PerAppVpnModeActivity.kt b/app/src/main/java/org/fptn/vpn/views/perappvpn/PerAppVpnModeActivity.kt new file mode 100644 index 00000000..60d5e196 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/views/perappvpn/PerAppVpnModeActivity.kt @@ -0,0 +1,73 @@ +package org.fptn.vpn.views.perappvpn + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.elvishew.xlog.XLog +import org.fptn.vpn.enums.PerAppVpnMode +import org.fptn.vpn.ui.screens.PerAppVpnScreen +import org.fptn.vpn.ui.theme.FptnTheme +import org.fptn.vpn.views.home.HomeActivity +import org.fptn.vpn.views.settings.SettingsActivity + +class PerAppVpnModeActivity : ComponentActivity() { + + private val tag = "PerAppVpnModeActivity" + private val viewModel: PerAppVpnModeViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + viewModel.loadInstalledApps(packageManager) + + setContent { + val mode by viewModel.perAppVpnModeMutableLiveData.observeAsState(PerAppVpnMode.OFF) + val appList by viewModel.appListMutableLiveData.observeAsState(emptyList()) + var showSystemApps by remember { mutableStateOf(false) } + val isLoading = appList.isEmpty() + + FptnTheme { + PerAppVpnScreen( + mode = mode ?: PerAppVpnMode.OFF, + appList = appList ?: emptyList(), + isLoading = isLoading, + showSystemApps = showSystemApps, + onModeChange = { viewModel.setPerAppVpnMode(it) }, + onAppToggle = { app -> + when (mode) { + PerAppVpnMode.ONLY_ALLOWED -> app.isAllowed = !app.isAllowed + PerAppVpnMode.EXCEPT_DISALLOWED -> app.isDisallowed = !app.isDisallowed + else -> Unit + } + // Trigger recomposition by posting the same list + viewModel.appListMutableLiveData.postValue( + viewModel.appListMutableLiveData.value + ) + }, + onShowSystemAppsChange = { show -> + showSystemApps = show + viewModel.setShowSystemApps(show) + }, + onSave = { + XLog.tag(tag).i("Per-app VPN mode saved [mode=%s]", mode) + viewModel.saveAllSettings() + finish() + }, + onCancel = { + XLog.tag(tag).i("Per-app VPN mode changes cancelled") + finish() + }, + onHome = { startActivity(Intent(this, HomeActivity::class.java)) }, + onSettings = { startActivity(Intent(this, SettingsActivity::class.java)) }, + ) + } + } + } +} diff --git a/app/src/main/java/org/fptn/vpn/views/settings/SettingsActivity.java b/app/src/main/java/org/fptn/vpn/views/settings/SettingsActivity.java deleted file mode 100644 index 620fe004..00000000 --- a/app/src/main/java/org/fptn/vpn/views/settings/SettingsActivity.java +++ /dev/null @@ -1,241 +0,0 @@ -package org.fptn.vpn.views.settings; - -import android.annotation.SuppressLint; -import android.app.AlertDialog; -import android.content.Intent; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Bundle; -import android.provider.Settings; -import android.text.Html; -import android.text.method.LinkMovementMethod; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ListAdapter; -import android.widget.ListView; -import android.widget.TextView; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.SwitchCompat; -import androidx.lifecycle.ViewModelProvider; - -import com.elvishew.xlog.XLog; -import com.google.android.material.bottomnavigation.BottomNavigationView; - -import org.fptn.vpn.R; -import org.fptn.vpn.utils.PermissionsUtils; -import org.fptn.vpn.views.CustomBottomNavigationListener; -import org.fptn.vpn.views.experimentalsettings.ExperimentalSettingsActivity; -import org.fptn.vpn.views.log.LogsActivity; -import org.fptn.vpn.views.splash.SplashActivity; -import org.fptn.vpn.views.adapter.ServerEntityAdapter; -import org.fptn.vpn.views.bypassmethod.BypassMethodsActivity; -import org.fptn.vpn.views.perappvpn.PerAppVpnModeActivity; -import org.fptn.vpn.views.updatetoken.UpdateTokenActivity; - -public class SettingsActivity extends AppCompatActivity { - private final String TAG = this.getClass().getSimpleName(); - - private ListView serverListView; - - private SettingsViewModel viewModel; - - private SwitchCompat permissionBatteryOptimizationButton; - private SwitchCompat permissionBackgroundDataTransferButton; - private BottomNavigationView bottomNavigationView; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.settings_layout); - - initializeVariable(); - } - - @SuppressLint("InlinedApi") - private void initializeVariable() { - bottomNavigationView = findViewById(R.id.bottomNavBar); - bottomNavigationView.setSelectedItemId(R.id.menuSettings); - bottomNavigationView.setOnItemSelectedListener(new CustomBottomNavigationListener(this, R.id.menuSettings)); - - - viewModel = new ViewModelProvider(this).get(SettingsViewModel.class); - viewModel.loadServersList(); - viewModel.getServerDtoListLiveData().observe(this, serverEntities -> { - if (serverEntities != null && !serverEntities.isEmpty()) { - serverListView.setAdapter(new ServerEntityAdapter(serverEntities, R.layout.settings_server_list_item)); - setListViewHeightBasedOnChildren(serverListView); - } - }); - serverListView = findViewById(R.id.settings_servers_list); - - try { - PackageInfo pInfo = this.getPackageManager().getPackageInfo(this.getPackageName(), 0); - final String version = pInfo.versionName; - - TextView versionTextView = findViewById(R.id.settings_fptn_version); - versionTextView.setText(version); - } catch (PackageManager.NameNotFoundException e) { - XLog.tag(TAG).e("Failed to read app version: %s", e.getMessage()); - } - - // about - TextView about = findViewById(R.id.settings_about); - about.setText(Html.fromHtml(getString(R.string.info_message_html), Html.FROM_HTML_MODE_LEGACY)); - about.setMovementMethod(LinkMovementMethod.getInstance()); - - // token's info - TextView tokenInfo = findViewById(R.id.settings_token_info_html); - tokenInfo.setText(Html.fromHtml(getString(R.string.settings_token_info_html), Html.FROM_HTML_MODE_LEGACY)); - tokenInfo.setMovementMethod(LinkMovementMethod.getInstance()); - - // Permission settings - permissionBatteryOptimizationButton = findViewById(R.id.permission_battery_optimization_button); - permissionBatteryOptimizationButton.setOnClickListener(view -> requestBatteryOptimisationPermission()); - - permissionBackgroundDataTransferButton = findViewById(R.id.permission_background_data_transfer_button); - permissionBackgroundDataTransferButton.setOnClickListener(view -> requestBackgroundDataTransferPermission()); - - // Our sponsors - TextView textView = findViewById(R.id.sponsors_list); - textView.setText(Html.fromHtml(getString(R.string.sponsors_usernames))); - - // Set on click listeners - View updateTokenLayout = findViewById(R.id.update_token_layout); - updateTokenLayout.setOnClickListener(this::onUpdateToken); - - View experimentalFeaturesLayout = findViewById(R.id.experimental_features_layout); - experimentalFeaturesLayout.setOnClickListener(this::onExperimentalSettings); - - View logoutLayout = findViewById(R.id.logout_layout); - logoutLayout.setOnClickListener(this::onLogout); - - // NEW: Bypass methods layout click listener - View bypassMethodsLayout = findViewById(R.id.bypass_methods_layout); - bypassMethodsLayout.setOnClickListener(this::onBypassMethods); - - // NEW: Per-app VPN mode layout click listener - View perAppVPNLayout = findViewById(R.id.per_app_vpn_mode_layout); - perAppVPNLayout.setOnClickListener(this::perAppVpnMode); - - // Logs viewer - View logsViewerLayout = findViewById(R.id.logs_viewer_layout); - if (logsViewerLayout != null) { - logsViewerLayout.setOnClickListener(v -> { - Intent intent = new Intent(SettingsActivity.this, LogsActivity.class); - startActivity(intent); - }); - } - } - - @Override - protected void onResume() { - super.onResume(); - - bottomNavigationView.setSelectedItemId(R.id.menuSettings); - - setPermissionButtonState(PermissionsUtils.checkBatteryOptimizations(this), permissionBatteryOptimizationButton); - setPermissionButtonState(PermissionsUtils.checkBackgroundDataTransferRestrictions(this), permissionBackgroundDataTransferButton); - } - - private void setPermissionButtonState(boolean isGranted, SwitchCompat switchView) { - switchView.setEnabled(true); - if (isGranted) { - switchView.setClickable(false); - switchView.setChecked(true); - } else { - switchView.setClickable(true); - switchView.setChecked(false); - } - } - - private void requestBatteryOptimisationPermission() { - new AlertDialog.Builder(this) - .setTitle(getString(R.string.battery_optimization_request_dialog_title)) - .setMessage(getString(R.string.battery_optimization_request_dialog_text)) - .setPositiveButton(getString(R.string.grant), (d, w) -> { - @SuppressLint("BatteryLife") Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); - intent.setData(Uri.parse("package:" + getPackageName())); - startActivity(intent); - }) - .setNegativeButton(getString(R.string.deny), (dialog, which) -> { - XLog.tag(TAG).w("Battery optimization exemption denied by user"); - permissionBatteryOptimizationButton.setChecked(false); - }) - .show(); - } - - private void requestBackgroundDataTransferPermission() { - /* If somebody worry about low speed in background - disable restriction on network transfer data*/ - new AlertDialog.Builder(this) - .setTitle(getString(R.string.background_data_request_dialog_title)) - .setMessage(getString(R.string.background_data_request_dialog_text)) - .setPositiveButton(getString(R.string.grant), (d, w) -> { - Intent intent = new Intent(Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS); - intent.setData(Uri.parse("package:" + getPackageName())); - startActivity(intent); - }) - .setNegativeButton(getString(R.string.deny), (dialog, which) -> { - XLog.tag(TAG).w("Background data transfer permission denied by user"); - permissionBackgroundDataTransferButton.setChecked(false); - }) - .show(); - } - - private void onExperimentalSettings(View view) { - Intent intent = new Intent(SettingsActivity.this, ExperimentalSettingsActivity.class); - startActivity(intent); - } - - public void onLogout(View v) { - AlertDialog.Builder builder = new AlertDialog.Builder(this) - .setTitle(R.string.dialog_logout_title) - .setMessage(R.string.dialog_logout_message) - .setPositiveButton(R.string.yes, (dialog, which) -> { - dialog.dismiss(); - viewModel.deleteAllServers(); - // goto Login activity - Intent intent = new Intent(SettingsActivity.this, SplashActivity.class); - startActivity(intent); - }) - .setNegativeButton(R.string.no, (dialog, which) -> dialog.dismiss()); - builder.show(); - } - - public void onUpdateToken(View v) { - // Goto update token - Intent intent = new Intent(SettingsActivity.this, UpdateTokenActivity.class); - startActivity(intent); - } - - public void onBypassMethods(View v) { - Intent intent = new Intent(SettingsActivity.this, BypassMethodsActivity.class); - startActivity(intent); - } - - private void perAppVpnMode(View view) { - Intent intent = new Intent(SettingsActivity.this, PerAppVpnModeActivity.class); - startActivity(intent); - } - - private static void setListViewHeightBasedOnChildren(ListView listView) { - ListAdapter listAdapter = listView.getAdapter(); - if (listAdapter == null) { - return; - } - - int totalHeight = 0; - for (int i = 0; i < listAdapter.getCount(); i++) { - View listItem = listAdapter.getView(i, null, listView); - listItem.measure(0, 0); - totalHeight += listItem.getMeasuredHeight(); - } - - ViewGroup.LayoutParams params = listView.getLayoutParams(); - params.height = totalHeight + (listView.getDividerHeight() * (listAdapter.getCount() - 1)); - listView.setLayoutParams(params); - listView.requestLayout(); - } -} diff --git a/app/src/main/java/org/fptn/vpn/views/settings/SettingsActivity.kt b/app/src/main/java/org/fptn/vpn/views/settings/SettingsActivity.kt new file mode 100644 index 00000000..0e2db804 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/views/settings/SettingsActivity.kt @@ -0,0 +1,125 @@ +package org.fptn.vpn.views.settings + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.elvishew.xlog.XLog +import org.fptn.vpn.R +import org.fptn.vpn.ui.screens.SettingsScreen +import org.fptn.vpn.ui.theme.FptnTheme +import org.fptn.vpn.utils.PermissionsUtils +import org.fptn.vpn.views.bypassmethod.BypassMethodsActivity +import org.fptn.vpn.views.experimentalsettings.ExperimentalSettingsActivity +import org.fptn.vpn.views.home.HomeActivity +import org.fptn.vpn.views.log.LogsActivity +import org.fptn.vpn.views.perappvpn.PerAppVpnModeActivity +import org.fptn.vpn.views.splash.SplashActivity +import org.fptn.vpn.views.updatetoken.UpdateTokenActivity + +class SettingsActivity : ComponentActivity() { + + private val viewModel: SettingsViewModel by viewModels() + private val tag = "SettingsActivity" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.loadServersList() + + setContent { + val serverList by viewModel.serverDtoListLiveData.observeAsState(emptyList()) + var batteryGranted by remember { mutableStateOf(PermissionsUtils.checkBatteryOptimizations(this)) } + var backgroundDataGranted by remember { mutableStateOf(PermissionsUtils.checkBackgroundDataTransferRestrictions(this)) } + + val appVersion = try { + packageManager.getPackageInfo(packageName, 0).versionName ?: "" + } catch (e: PackageManager.NameNotFoundException) { + "" + } + + FptnTheme { + SettingsScreen( + serverList = serverList ?: emptyList(), + appVersion = appVersion, + aboutHtml = getString(R.string.info_message_html), + tokenInfoHtml = getString(R.string.settings_token_info_html), + sponsorsHtml = getString(R.string.sponsors_usernames), + batteryOptGranted = batteryGranted, + backgroundDataGranted = backgroundDataGranted, + onBatteryOptClick = { requestBatteryOptimisationPermission { batteryGranted = PermissionsUtils.checkBatteryOptimizations(this) } }, + onBackgroundDataClick = { requestBackgroundDataTransferPermission { backgroundDataGranted = PermissionsUtils.checkBackgroundDataTransferRestrictions(this) } }, + onUpdateToken = { startActivity(Intent(this, UpdateTokenActivity::class.java)) }, + onBypassMethods = { startActivity(Intent(this, BypassMethodsActivity::class.java)) }, + onPerAppVpn = { startActivity(Intent(this, PerAppVpnModeActivity::class.java)) }, + onExperimentalSettings = { startActivity(Intent(this, ExperimentalSettingsActivity::class.java)) }, + onLogs = { startActivity(Intent(this, LogsActivity::class.java)) }, + onLogout = { confirmLogout() }, + onHome = { startActivity(Intent(this, HomeActivity::class.java)) }, + onSettings = { /* already here */ }, + ) + } + } + } + + override fun onResume() { + super.onResume() + viewModel.loadServersList() + } + + private fun confirmLogout() { + AlertDialog.Builder(this) + .setTitle(R.string.dialog_logout_title) + .setMessage(R.string.dialog_logout_message) + .setPositiveButton(R.string.yes) { dialog, _ -> + dialog.dismiss() + viewModel.deleteAllServers() + startActivity(Intent(this, SplashActivity::class.java)) + } + .setNegativeButton(R.string.no) { dialog, _ -> dialog.dismiss() } + .show() + } + + @SuppressLint("BatteryLife") + private fun requestBatteryOptimisationPermission(onDone: () -> Unit) { + AlertDialog.Builder(this) + .setTitle(getString(R.string.battery_optimization_request_dialog_title)) + .setMessage(getString(R.string.battery_optimization_request_dialog_text)) + .setPositiveButton(getString(R.string.grant)) { _, _ -> + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + intent.data = Uri.parse("package:$packageName") + startActivity(intent) + onDone() + } + .setNegativeButton(getString(R.string.deny)) { _, _ -> + XLog.tag(tag).w("Battery optimization exemption denied by user") + } + .show() + } + + private fun requestBackgroundDataTransferPermission(onDone: () -> Unit) { + AlertDialog.Builder(this) + .setTitle(getString(R.string.background_data_request_dialog_title)) + .setMessage(getString(R.string.background_data_request_dialog_text)) + .setPositiveButton(getString(R.string.grant)) { _, _ -> + val intent = Intent(Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS) + intent.data = Uri.parse("package:$packageName") + startActivity(intent) + onDone() + } + .setNegativeButton(getString(R.string.deny)) { _, _ -> + XLog.tag(tag).w("Background data transfer permission denied by user") + } + .show() + } +} 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 deleted file mode 100644 index c1f1a0c9..00000000 --- a/app/src/main/java/org/fptn/vpn/views/splash/SplashActivity.java +++ /dev/null @@ -1,61 +0,0 @@ -package org.fptn.vpn.views.splash; - -import android.annotation.SuppressLint; -import android.content.Intent; -import android.os.Bundle; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; - -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.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; - -@SuppressLint("CustomSplashScreen") -public class SplashActivity extends AppCompatActivity { - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.splash_layout); - - initializeVariable(); - } - - private void initializeVariable() { - ListenableFuture countFuture = AppDatabase.getInstance(this).serverDAO().getCount(); - Futures.addCallback(countFuture, new FutureCallback<>() { - @Override - public void onSuccess(Integer count) { - Intent intent; - if (count > 0) { - 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); - } - startActivity(intent); - finish(); - } - - @Override - public void onFailure(Throwable t) { - // Default to Login on error - startActivity(new Intent(SplashActivity.this, LoginActivity.class)); - finish(); - } - }, getMainExecutor()); - } - -} diff --git a/app/src/main/java/org/fptn/vpn/views/splash/SplashActivity.kt b/app/src/main/java/org/fptn/vpn/views/splash/SplashActivity.kt new file mode 100644 index 00000000..1a1dfdc4 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/views/splash/SplashActivity.kt @@ -0,0 +1,53 @@ +package org.fptn.vpn.views.splash + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.google.common.util.concurrent.FutureCallback +import com.google.common.util.concurrent.Futures +import org.fptn.vpn.database.AppDatabase +import org.fptn.vpn.services.snichecker.SniCheckerService +import org.fptn.vpn.services.snichecker.SniCheckerServiceState +import org.fptn.vpn.ui.screens.SplashScreen +import org.fptn.vpn.ui.theme.FptnTheme +import org.fptn.vpn.views.bypassmethod.BypassMethodsActivity +import org.fptn.vpn.views.home.HomeActivity +import org.fptn.vpn.views.login.LoginActivity + +@SuppressLint("CustomSplashScreen") +class SplashActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + FptnTheme { SplashScreen() } + } + navigate() + } + + private fun navigate() { + val countFuture = AppDatabase.getInstance(this).serverDAO().getCount() + Futures.addCallback(countFuture, object : FutureCallback { + override fun onSuccess(count: Int?) { + val sniActive = SniCheckerService.getStaticServiceState().value == SniCheckerServiceState.ACTIVE + val intent = when { + (count ?: 0) > 0 && sniActive -> + Intent(this@SplashActivity, BypassMethodsActivity::class.java) + (count ?: 0) > 0 -> + Intent(this@SplashActivity, HomeActivity::class.java) + else -> + Intent(this@SplashActivity, LoginActivity::class.java) + } + startActivity(intent) + finish() + } + + override fun onFailure(t: Throwable) { + startActivity(Intent(this@SplashActivity, LoginActivity::class.java)) + finish() + } + }, mainExecutor) + } +} diff --git a/app/src/main/java/org/fptn/vpn/views/updatetoken/UpdateTokenActivity.java b/app/src/main/java/org/fptn/vpn/views/updatetoken/UpdateTokenActivity.java deleted file mode 100644 index c0cb0592..00000000 --- a/app/src/main/java/org/fptn/vpn/views/updatetoken/UpdateTokenActivity.java +++ /dev/null @@ -1,117 +0,0 @@ -package org.fptn.vpn.views.updatetoken; - -import android.annotation.SuppressLint; -import android.content.Intent; -import android.os.Bundle; -import android.text.Html; -import android.text.method.LinkMovementMethod; -import android.view.MotionEvent; -import android.view.View; -import android.view.WindowManager; -import android.widget.EditText; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.lifecycle.ViewModelProvider; - -import org.fptn.vpn.R; -import org.fptn.vpn.views.CustomBottomNavigationListener; -import org.fptn.vpn.views.settings.SettingsActivity; - -import com.elvishew.xlog.XLog; -import com.google.android.material.bottomnavigation.BottomNavigationView; -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; - -import lombok.Getter; - -public class UpdateTokenActivity extends AppCompatActivity { - private static final String TAG = UpdateTokenActivity.class.getSimpleName(); - - @Getter - private UpdateTokenViewModel viewModel; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.settings_layout_update_token); - - initializeVariable(); - } - - @SuppressLint("ClickableViewAccessibility") - private void initializeVariable() { - viewModel = new ViewModelProvider(this).get(UpdateTokenViewModel.class); - - BottomNavigationView bottomNavigationView = findViewById(R.id.bottomNavBar); - bottomNavigationView.setSelectedItemId(R.id.menuSettings); - bottomNavigationView.setOnItemSelectedListener(new CustomBottomNavigationListener(this, R.id.menuSettings)); - - TextView errorTextView = findViewById(R.id.errorTextView); - viewModel.getErrorTextLiveData().observe(this, errorTextView::setText); - - // Show HTML - TextView label = findViewById(R.id.fptn_login_html_label); - label.setText(Html.fromHtml(getString(R.string.telegram_bot_html), Html.FROM_HTML_MODE_LEGACY)); - label.setMovementMethod(LinkMovementMethod.getInstance()); - - // HIDE KEYBOARD - EditText editText = findViewById(R.id.fptn_login_link_input); - editText.setTextIsSelectable(true); - editText.setShowSoftInputOnFocus(false); - editText.setOnTouchListener((view, motionEvent) -> { - if (motionEvent.getAction() == MotionEvent.ACTION_UP) { - if (motionEvent.getX() > (view.getWidth() - view.getPaddingRight() - 50)) { - ((EditText) view).setText(""); - } - } - return false; - }); - getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN); // This just hide keyboard when activity starts - } - - public void onCancel(View v) { - Intent intent = new Intent(this, SettingsActivity.class); - startActivity(intent); - - finish(); - } - - public void onSave(View v) { - EditText linkInput = findViewById(R.id.fptn_login_link_input); - final String tokenLink = linkInput.getText().toString(); - - try { - ListenableFuture updateResult = viewModel.parseAndSaveToken(tokenLink); - Futures.addCallback(updateResult, new FutureCallback<>() { - @Override - public void onSuccess(Void result) { - Toast.makeText(getApplicationContext(), R.string.token_was_updated, Toast.LENGTH_SHORT).show(); - - Intent intent = new Intent(UpdateTokenActivity.this, SettingsActivity.class); - startActivity(intent); - - finish(); - } - - @Override - public void onFailure(Throwable t) { - XLog.tag(TAG).e("Token update failed: %s", t.getMessage()); - Toast.makeText(getApplicationContext(), t.getMessage(), Toast.LENGTH_SHORT).show(); - - viewModel.getErrorTextLiveData().postValue(t.getMessage()); - } - - }, getMainExecutor()); - } catch (Exception e) { - XLog.tag(TAG).e("Token parsing failed: %s", e.getMessage()); - Toast.makeText(getApplicationContext(), R.string.token_saving_failed, Toast.LENGTH_SHORT).show(); - - viewModel.getErrorTextLiveData().postValue(getString(R.string.token_saving_failed)); - } - } - -} diff --git a/app/src/main/java/org/fptn/vpn/views/updatetoken/UpdateTokenActivity.kt b/app/src/main/java/org/fptn/vpn/views/updatetoken/UpdateTokenActivity.kt new file mode 100644 index 00000000..b0788d48 --- /dev/null +++ b/app/src/main/java/org/fptn/vpn/views/updatetoken/UpdateTokenActivity.kt @@ -0,0 +1,70 @@ +package org.fptn.vpn.views.updatetoken + +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import com.elvishew.xlog.XLog +import com.google.common.util.concurrent.FutureCallback +import com.google.common.util.concurrent.Futures +import org.fptn.vpn.R +import org.fptn.vpn.ui.screens.UpdateTokenScreen +import org.fptn.vpn.ui.theme.FptnTheme +import org.fptn.vpn.views.home.HomeActivity +import org.fptn.vpn.views.settings.SettingsActivity + +class UpdateTokenActivity : ComponentActivity() { + + private val viewModel: UpdateTokenViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + val errorText by viewModel.errorTextLiveData.observeAsState("") + FptnTheme { + UpdateTokenScreen( + errorText = errorText ?: "", + htmlLabel = getString(R.string.telegram_bot_html), + onSave = ::onSave, + onCancel = { + startActivity(Intent(this, SettingsActivity::class.java)) + finish() + }, + onHome = { + startActivity(Intent(this, HomeActivity::class.java)) + }, + onSettings = { + startActivity(Intent(this, SettingsActivity::class.java)) + }, + ) + } + } + } + + private fun onSave(tokenLink: String) { + try { + val future = viewModel.parseAndSaveToken(tokenLink) + Futures.addCallback(future, object : FutureCallback { + override fun onSuccess(result: Void?) { + Toast.makeText(applicationContext, R.string.token_was_updated, Toast.LENGTH_SHORT).show() + startActivity(Intent(this@UpdateTokenActivity, SettingsActivity::class.java)) + finish() + } + + override fun onFailure(t: Throwable) { + XLog.tag("UpdateTokenActivity").e("Token update failed: %s", t.message) + Toast.makeText(applicationContext, t.message, Toast.LENGTH_SHORT).show() + viewModel.errorTextLiveData.postValue(t.message) + } + }, mainExecutor) + } catch (e: Exception) { + XLog.tag("UpdateTokenActivity").e("Token parsing failed: %s", e.message) + Toast.makeText(applicationContext, R.string.token_saving_failed, Toast.LENGTH_SHORT).show() + viewModel.errorTextLiveData.postValue(getString(R.string.token_saving_failed)) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8506a215..12316a93 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,8 @@ [versions] +accompanist = "0.36.0" activity = "1.10.1" +composeBom = "2025.04.01" +lifecycleViewmodelCompose = "2.8.4" androidGradlePlugin = "8.13.2" androidlombock = "1.18.42" appcompat = "1.7.1" @@ -30,9 +33,18 @@ runner = "1.7.0" zxing = "3.5.4" [libraries] +accompanist-drawablepainter = { group = "com.google.accompanist", name = "accompanist-drawablepainter", version.ref = "accompanist" } +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" } android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } androidlombock = { group = "org.projectlombok", name = "lombok", version.ref = "androidlombock" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitTest" } @@ -66,5 +78,7 @@ zxing = { module = "com.google.zxing:core", version.ref = "zxing" } [plugins] crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "crashlytics" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-lombok = { id = "org.jetbrains.kotlin.plugin.lombok", version.ref = "kotlin" } deps-sorting = { id = "com.squareup.sort-dependencies", version.ref = "autonomousappsSorting" } deps-unused = { id = "com.autonomousapps.dependency-analysis", version.ref = "autonomousappsUnused" } From ae0de0372f487f29847fcf6d1087ed84104ee599 Mon Sep 17 00:00:00 2001 From: Stas Skokov <7090stas@gmail.com> Date: Thu, 11 Jun 2026 21:50:31 +1000 Subject: [PATCH 3/3] fix ui --- .../org/fptn/vpn/ui/screens/HomeScreen.kt | 289 ++++++----- .../org/fptn/vpn/ui/screens/SettingsScreen.kt | 486 +++++++++++++----- 2 files changed, 508 insertions(+), 267 deletions(-) diff --git a/app/src/main/java/org/fptn/vpn/ui/screens/HomeScreen.kt b/app/src/main/java/org/fptn/vpn/ui/screens/HomeScreen.kt index ccf5c0fb..ec6fa2c2 100644 --- a/app/src/main/java/org/fptn/vpn/ui/screens/HomeScreen.kt +++ b/app/src/main/java/org/fptn/vpn/ui/screens/HomeScreen.kt @@ -1,7 +1,8 @@ package org.fptn.vpn.ui.screens +import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -12,15 +13,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold @@ -32,12 +30,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -46,9 +42,7 @@ import org.fptn.vpn.database.entity.ServerEntity import org.fptn.vpn.enums.ConnectionState import org.fptn.vpn.ui.components.BottomNavBar import org.fptn.vpn.ui.components.BottomNavTab -import org.fptn.vpn.ui.theme.DeniedRed import org.fptn.vpn.ui.theme.Primary -import org.fptn.vpn.ui.theme.Secondary import org.fptn.vpn.ui.theme.White import org.fptn.vpn.ui.theme.White10 import org.fptn.vpn.ui.theme.Yellow @@ -93,40 +87,165 @@ fun HomeScreen( .fillMaxSize() .background(Primary) .padding(padding) - .padding(horizontal = 16.dp, vertical = 24.dp), + .padding(horizontal = 10.dp), horizontalAlignment = Alignment.CenterHorizontally ) { + // Permission warning row (always occupies 80dp to avoid layout shift) + Box( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .padding(top = 20.dp), + contentAlignment = Alignment.Center + ) { + if (showPermissionWarning) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(R.drawable.warning), + contentDescription = null, + modifier = Modifier.size(40.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = stringResource(R.string.permission_warning_text), + color = White, + fontSize = 14.sp, + modifier = Modifier.weight(1f) + ) + } + } + } + + // Timer row (always occupies 80dp to avoid layout shift) + Box( + modifier = Modifier + .fillMaxWidth() + .height(80.dp), + contentAlignment = Alignment.Center + ) { + if (isConnected) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.connection_time), + color = White, + fontSize = 14.sp, + ) + Text( + text = timerText, + color = White, + fontSize = 16.sp, + ) + } + } + } + + // Toggle button (180x180dp) + Image( + painter = painterResource( + if (isActive) R.drawable.toggle_button_on else R.drawable.toggle_button_off + ), + contentDescription = null, + modifier = Modifier + .size(180.dp) + .clickable { onConnectDisconnect() } + ) + + // Status text Text( text = statusText, - color = White, + color = Yellow, fontSize = 16.sp, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .padding(10.dp) ) - if (errorText.isNotEmpty()) { - Text( - text = errorText, - color = Yellow, - fontSize = 14.sp, - textAlign = TextAlign.Center, + // Connected server info + if (connectedServerInfo.isNotEmpty()) { + Row( modifier = Modifier .fillMaxWidth() - .padding(top = 4.dp) - ) + .padding(horizontal = 10.dp), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.server_label) + " ", + color = White, + fontSize = 14.sp, + ) + Text( + text = connectedServerInfo, + color = White, + fontSize = 14.sp, + ) + } } - Spacer(modifier = Modifier.height(32.dp)) - - ConnectButton( - isActive = isActive, - connectionState = connectionState, - onClick = onConnectDisconnect, - ) - - Spacer(modifier = Modifier.height(24.dp)) + // Speed card with icons + if (isActive) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp, top = 10.dp, end = 10.dp) + .background(White10, RoundedCornerShape(20.dp)) + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Download + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.download), + contentDescription = null, + modifier = Modifier + .size(32.dp) + .padding(end = 10.dp) + ) + Text( + text = downloadSpeed, + color = White, + fontSize = 14.sp, + modifier = Modifier.weight(1f), + textAlign = TextAlign.End + ) + } + // Upload + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = uploadSpeed, + color = White, + fontSize = 14.sp, + modifier = Modifier + .weight(1f) + .padding(start = 10.dp), + textAlign = TextAlign.Start + ) + Image( + painter = painterResource(R.drawable.upload), + contentDescription = null, + modifier = Modifier + .size(32.dp) + .padding(start = 10.dp) + ) + } + } + } + // Server spinner (when not active) if (!isActive && serverList.isNotEmpty()) { + Spacer(modifier = Modifier.height(5.dp)) ServerDropdown( servers = serverList, selected = selectedServer, @@ -134,72 +253,22 @@ fun HomeScreen( ) } - if (isConnected) { - Spacer(modifier = Modifier.height(16.dp)) - - ConnectedInfoCard( - timerText = timerText, - downloadSpeed = downloadSpeed, - uploadSpeed = uploadSpeed, - connectedServerInfo = connectedServerInfo, - ) - } - - if (showPermissionWarning) { - Spacer(modifier = Modifier.height(16.dp)) + // Error text + if (errorText.isNotEmpty()) { Text( - text = stringResource(R.string.permission_warning_text), - color = DeniedRed, + text = errorText, + color = Yellow, fontSize = 14.sp, textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() - .background(Color(0x37000000), RoundedCornerShape(8.dp)) - .padding(8.dp) + .padding(horizontal = 20.dp, vertical = 20.dp) ) } } } } -@Composable -private fun ConnectButton( - isActive: Boolean, - connectionState: ConnectionState, - onClick: () -> Unit, -) { - val borderColor = if (isActive) Secondary else White10 - val innerColor = if (isActive) Secondary else White10 - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(180.dp) - .clip(CircleShape) - .border(4.dp, borderColor, CircleShape) - ) { - Button( - onClick = onClick, - modifier = Modifier.size(148.dp), - shape = CircleShape, - colors = ButtonDefaults.buttonColors( - containerColor = innerColor, - contentColor = Primary, - ), - ) { - Icon( - painter = painterResource( - if (isActive) R.drawable.ic_baseline_logout_24 - else R.drawable.ic_baseline_update_24 - ), - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = Primary, - ) - } - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ServerDropdown( @@ -213,7 +282,9 @@ private fun ServerDropdown( ExposedDropdownMenuBox( expanded = expanded, onExpandedChange = { expanded = !expanded }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 5.dp), ) { OutlinedTextField( value = displayText, @@ -222,7 +293,8 @@ private fun ServerDropdown( trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, modifier = Modifier .menuAnchor() - .fillMaxWidth(), + .fillMaxWidth() + .height(52.dp), colors = OutlinedTextFieldDefaults.colors( focusedTextColor = White, unfocusedTextColor = White, @@ -278,48 +350,5 @@ private fun getPingEmoji(ping: Long) = when { else -> "🔴" } -@Composable -private fun ConnectedInfoCard( - timerText: String, - downloadSpeed: String, - uploadSpeed: String, - connectedServerInfo: String, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(White10, RoundedCornerShape(16.dp)) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(text = "↓ $downloadSpeed", color = White, fontSize = 14.sp) - } - Text( - text = timerText, - color = Secondary, - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - ) - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(text = "↑ $uploadSpeed", color = White, fontSize = 14.sp) - } - } - if (connectedServerInfo.isNotEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = connectedServerInfo, - color = White, - fontSize = 14.sp, - textAlign = TextAlign.Center, - ) - } - } -} - private val ServerEntity.isAuto: Boolean get() = name == "Auto" diff --git a/app/src/main/java/org/fptn/vpn/ui/screens/SettingsScreen.kt b/app/src/main/java/org/fptn/vpn/ui/screens/SettingsScreen.kt index b63ca043..599e5e90 100644 --- a/app/src/main/java/org/fptn/vpn/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/org/fptn/vpn/ui/screens/SettingsScreen.kt @@ -11,10 +11,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Divider import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch @@ -23,7 +23,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -81,165 +80,352 @@ fun SettingsScreen( .fillMaxSize() .background(Primary) .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally ) { - SectionHeader(text = stringResource(R.string.your_servers)) + // Settings icon at top + Icon( + painter = painterResource(R.drawable.icon_settings_circle_100), + contentDescription = null, + tint = Secondary, + modifier = Modifier + .size(80.dp) + .padding(top = 30.dp) + ) - serverList.forEach { server -> - ServerListItem(server = server) + // Version + Text( + text = appVersion, + color = White, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 10.dp) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // About info card + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .background(White10, RoundedCornerShape(20.dp)) + .padding(10.dp) + ) { + HtmlText( + html = aboutHtml, + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp, top = 5.dp, end = 20.dp, bottom = 10.dp), + textColor = White, + textSizeSp = 14f, + ) } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(20.dp)) - SettingsNavigationItem( - icon = R.drawable.ic_baseline_update_24, - title = stringResource(R.string.update_token_button), - onClick = onUpdateToken, - ) + // Update Token card + SettingsCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .clickable { onUpdateToken() } + ) { + NavRowItem( + icon = R.drawable.ic_baseline_update_24, + title = stringResource(R.string.update_token_button), + ) + HtmlText( + html = tokenInfoHtml, + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp, top = 10.dp, end = 10.dp, bottom = 10.dp), + textColor = Hint, + textSizeSp = 12f, + ) + } - SettingsNavigationItem( - icon = R.drawable.ic_baseline_your_servers_24, - title = stringResource(R.string.bypass_methods_title), - onClick = onBypassMethods, - ) + Spacer(modifier = Modifier.height(20.dp)) - SettingsNavigationItem( - icon = R.drawable.ic_per_app_vpn_mode, - title = stringResource(R.string.per_app_vpn_mode_title), - onClick = onPerAppVpn, - ) + // Bypass Methods card + SettingsCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .clickable { onBypassMethods() } + ) { + NavRowItem( + icon = R.drawable.ic_logo_24, + title = stringResource(R.string.bypass_methods_title), + ) + Text( + text = stringResource(R.string.bypass_methods_info), + color = Hint, + fontSize = 12.sp, + modifier = Modifier.padding(start = 10.dp, top = 10.dp, end = 10.dp, bottom = 10.dp) + ) + } - SettingsNavigationItem( - icon = R.drawable.ic_experimental_features_24, - title = stringResource(R.string.experimental_features_label), - onClick = onExperimentalSettings, - ) + Spacer(modifier = Modifier.height(20.dp)) - SettingsNavigationItem( - icon = R.drawable.ic_baseline_menu_24, - title = stringResource(R.string.logs), - onClick = onLogs, - ) + // Per-App VPN card + SettingsCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .clickable { onPerAppVpn() } + ) { + NavRowItem( + icon = R.drawable.ic_per_app_vpn_mode, + title = stringResource(R.string.per_app_vpn_mode_title), + ) + Text( + text = stringResource(R.string.per_app_vpn_mode_info), + color = Hint, + fontSize = 12.sp, + modifier = Modifier.padding(start = 10.dp, top = 10.dp, end = 10.dp, bottom = 10.dp) + ) + } - Spacer(modifier = Modifier.height(16.dp)) - SectionHeader(text = stringResource(R.string.required_permissions)) + Spacer(modifier = Modifier.height(20.dp)) - PermissionSwitch( - label = stringResource(R.string.battery_optimization_request_dialog_title), - granted = batteryOptGranted, - onClick = onBatteryOptClick, - ) + // Required Permissions card + SettingsCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) { + // Section header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.user_permissions_24), + contentDescription = null, + tint = White, + modifier = Modifier + .height(16.dp) + .padding(start = 0.dp, end = 8.dp) + ) + Text( + text = stringResource(R.string.required_permissions), + color = White, + fontSize = 14.sp, + ) + } - PermissionSwitch( - label = stringResource(R.string.background_data_request_dialog_title), - granted = backgroundDataGranted, - onClick = onBackgroundDataClick, - ) + // Battery optimization + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 14.dp, top = 6.dp, bottom = 4.dp) + ) { + PermissionSwitchRow( + icon = R.drawable.eco_battery_24, + label = stringResource(R.string.battery_optimization_request_dialog_title), + granted = batteryOptGranted, + onClick = onBatteryOptClick, + ) + + PermissionSwitchRow( + icon = R.drawable.cloud_back_up_24, + label = stringResource(R.string.background_data_request_dialog_title), + granted = backgroundDataGranted, + onClick = onBackgroundDataClick, + ) + } + } - Spacer(modifier = Modifier.height(16.dp)) - SectionHeader(text = stringResource(R.string.sponsors_text)) + Spacer(modifier = Modifier.height(20.dp)) - HtmlText( - html = sponsorsHtml, + // Experimental Features card + SettingsCard( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), - textColor = White, - textSizeSp = 14f, - ) + .padding(horizontal = 20.dp) + .clickable { onExperimentalSettings() } + ) { + NavRowItem( + icon = R.drawable.ic_experimental_features_24, + title = stringResource(R.string.experimental_features_label), + ) + Text( + text = stringResource(R.string.experimental_features_info), + color = Hint, + fontSize = 12.sp, + modifier = Modifier.padding(start = 10.dp, top = 10.dp, end = 10.dp, bottom = 10.dp) + ) + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(20.dp)) - HtmlText( - html = tokenInfoHtml, + // Your Servers card + SettingsCard( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), - textColor = White, - textSizeSp = 14f, - ) + .padding(horizontal = 20.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_your_servers_24), + contentDescription = null, + tint = White, + modifier = Modifier + .height(32.dp) + .padding(horizontal = 12.dp) + ) + Text( + text = stringResource(R.string.your_servers), + color = White, + fontSize = 14.sp, + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp) + ) { + serverList.forEach { server -> + ServerListItem(server = server) + } + } + } + + Spacer(modifier = Modifier.height(20.dp)) - HtmlText( - html = aboutHtml, + // Sponsors card + SettingsCard( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), - textColor = White, - textSizeSp = 14f, - ) + .padding(horizontal = 20.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_sponsors_update_24), + contentDescription = null, + tint = White, + modifier = Modifier + .height(32.dp) + .padding(horizontal = 12.dp) + ) + Text( + text = stringResource(R.string.sponsors_text), + color = White, + fontSize = 14.sp, + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, bottom = 8.dp) + ) { + HtmlText( + html = sponsorsHtml, + modifier = Modifier.fillMaxWidth(), + textColor = White, + textSizeSp = 14f, + ) + } + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(20.dp)) - Text( - text = appVersion, - color = Hint, - fontSize = 12.sp, - modifier = Modifier.padding(vertical = 4.dp) - ) + // Logs card + SettingsCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .clickable { onLogs() } + ) { + NavRowItem( + icon = R.drawable.ic_logo_24, + title = stringResource(R.string.logs), + ) + Text( + text = stringResource(R.string.view_and_copy_application_logs), + color = Hint, + fontSize = 12.sp, + modifier = Modifier.padding(start = 10.dp, top = 10.dp, end = 10.dp, bottom = 10.dp) + ) + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(20.dp)) - SettingsNavigationItem( - icon = R.drawable.ic_baseline_logout_24, - title = stringResource(R.string.logout_button), - onClick = onLogout, - tint = Color(0xFFCC0000), - ) + // Logout card + SettingsCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .clickable { onLogout() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(R.drawable.ic_baseline_logout_24), + contentDescription = null, + tint = White, + modifier = Modifier + .height(32.dp) + .padding(horizontal = 12.dp) + ) + Text( + text = stringResource(R.string.logout_button), + color = White, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + } + Icon( + painter = painterResource(R.drawable.ic_outline_arrow_forward_ios_16), + contentDescription = null, + tint = White, + modifier = Modifier.padding(end = 4.dp) + ) + } + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(20.dp)) } } } @Composable -private fun SectionHeader(text: String) { - Text( - text = text, - color = Secondary, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(vertical = 8.dp) - ) - Divider(color = White10, thickness = 1.dp) - Spacer(modifier = Modifier.height(8.dp)) -} - -@Composable -private fun ServerListItem(server: ServerEntity) { - val flag = CountryFlags.getCountryFlagByCountryCode(server.countryCode) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween +private fun SettingsCard( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Column( + modifier = modifier + .background(White10, RoundedCornerShape(20.dp)) + .padding(10.dp) ) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (flag != null) { - Text(text = flag, fontSize = 20.sp, modifier = Modifier.padding(end = 8.dp)) - } - Column { - Text(text = server.name, color = White, fontSize = 14.sp) - Text(text = server.host, color = Hint, fontSize = 12.sp) - } - } + content() } - Divider(color = White10, thickness = 0.5.dp) } @Composable -private fun SettingsNavigationItem( - icon: Int, - title: String, - onClick: () -> Unit, - tint: Color = White, -) { +private fun NavRowItem(icon: Int, title: String) { Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(vertical = 14.dp), + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -247,30 +433,30 @@ private fun SettingsNavigationItem( Icon( painter = painterResource(icon), contentDescription = null, - tint = tint, + tint = White, modifier = Modifier - .size(24.dp) - .padding(end = 0.dp) + .height(32.dp) + .padding(horizontal = 12.dp) ) Text( text = title, - color = tint, - fontSize = 16.sp, - modifier = Modifier.padding(start = 16.dp) + color = White, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, ) } Icon( - painter = painterResource(R.drawable.ic_outline_arrow_forward_ios_24), + painter = painterResource(R.drawable.ic_outline_arrow_forward_ios_16), contentDescription = null, - tint = White10, - modifier = Modifier.size(16.dp) + tint = White, + modifier = Modifier.padding(end = 4.dp) ) } - Divider(color = White10, thickness = 0.5.dp) } @Composable -private fun PermissionSwitch( +private fun PermissionSwitchRow( + icon: Int, label: String, granted: Boolean, onClick: () -> Unit, @@ -278,10 +464,17 @@ private fun PermissionSwitch( Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + .padding(vertical = 0.dp), + verticalAlignment = Alignment.CenterVertically ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = White, + modifier = Modifier + .size(28.dp) + .padding(end = 8.dp) + ) Text( text = label, color = White, @@ -292,6 +485,7 @@ private fun PermissionSwitch( checked = granted, onCheckedChange = { if (!granted) onClick() }, enabled = !granted, + modifier = Modifier.padding(end = 2.dp), colors = SwitchDefaults.colors( checkedThumbColor = Primary, checkedTrackColor = GrantedGreen, @@ -300,5 +494,23 @@ private fun PermissionSwitch( ) ) } - Divider(color = White10, thickness = 0.5.dp) +} + +@Composable +private fun ServerListItem(server: ServerEntity) { + val flag = CountryFlags.getCountryFlagByCountryCode(server.countryCode) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (flag != null) { + Text(text = flag, fontSize = 20.sp, modifier = Modifier.padding(end = 8.dp)) + } + Column { + Text(text = server.name, color = White, fontSize = 14.sp) + Text(text = server.host, color = Hint, fontSize = 12.sp) + } + } }