diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2fa86dc..c8851de 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -155,6 +155,7 @@ android {
licensee {
Constants.allowedLicenses.forEach { allow(it) }
allowUrl(Constants.ANDROID_TERMS_URL)
+ allowUrl(Constants.XZING_LICENSE_URL)
}
gross { enableAndroidAssetGeneration.set(true) }
@@ -247,4 +248,7 @@ dependencies {
implementation(libs.moshi.kotlin)
// warning here https://github.com/square/moshi/discussions/1752
ksp(libs.moshi.kotlin.codegen)
+
+ // barcode scanning
+ implementation(libs.zxing.android.embedded)
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c3a0396..420e0d4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,7 +4,6 @@
-
@@ -35,6 +34,10 @@
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
+
}
diff --git a/app/src/main/java/net/nymtech/nymvpn/data/datastore/EncryptedPreferences.kt b/app/src/main/java/net/nymtech/nymvpn/data/datastore/EncryptedPreferences.kt
index b4361a5..3911239 100644
--- a/app/src/main/java/net/nymtech/nymvpn/data/datastore/EncryptedPreferences.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/data/datastore/EncryptedPreferences.kt
@@ -1,8 +1,14 @@
package net.nymtech.nymvpn.data.datastore
import android.content.Context
+import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.onStart
+import timber.log.Timber
class EncryptedPreferences(context: Context) {
companion object {
@@ -21,3 +27,38 @@ class EncryptedPreferences(context: Context) {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
+
+inline fun SharedPreferences.observeKey(key: String, default: T?): Flow {
+ val flow = MutableStateFlow(getItem(key, default))
+
+ val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, k ->
+ if (key == k) {
+ try {
+ flow.value = getItem(key, default)
+ } catch (e: IllegalArgumentException) {
+ Timber.e(e)
+ flow.value = null
+ } catch (e: ClassCastException) {
+ Timber.e(e)
+ flow.value = null
+ }
+ }
+ }
+
+ return flow
+ .onCompletion { unregisterOnSharedPreferenceChangeListener(listener) }
+ .onStart { registerOnSharedPreferenceChangeListener(listener) }
+}
+
+inline fun SharedPreferences.getItem(key: String, default: T?): T? {
+ @Suppress("UNCHECKED_CAST")
+ return when (default) {
+ is String? -> getString(key, default) as T?
+ is Int -> getInt(key, default) as T
+ is Long -> getLong(key, default) as T
+ is Boolean -> getBoolean(key, default) as T
+ is Float -> getFloat(key, default) as T
+ is Set<*> -> getStringSet(key, default as Set) as T
+ else -> throw IllegalArgumentException("generic type not handle ${T::class.java.name}")
+ }
+}
diff --git a/app/src/main/java/net/nymtech/nymvpn/data/datastore/SecretsPreferencesRepository.kt b/app/src/main/java/net/nymtech/nymvpn/data/datastore/SecretsPreferencesRepository.kt
index 1ea2a38..e88c6ac 100644
--- a/app/src/main/java/net/nymtech/nymvpn/data/datastore/SecretsPreferencesRepository.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/data/datastore/SecretsPreferencesRepository.kt
@@ -1,5 +1,6 @@
package net.nymtech.nymvpn.data.datastore
+import kotlinx.coroutines.flow.Flow
import net.nymtech.nymvpn.data.SecretsRepository
import timber.log.Timber
@@ -20,4 +21,6 @@ class SecretsPreferencesRepository(private val encryptedPreferences: EncryptedPr
override suspend fun saveCredential(credential: String) {
encryptedPreferences.sharedPreferences.edit().putString(CRED, credential).apply()
}
+
+ override val credentialFlow: Flow = encryptedPreferences.sharedPreferences.observeKey(CRED, null)
}
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/AppUiState.kt b/app/src/main/java/net/nymtech/nymvpn/ui/AppUiState.kt
index 794193b..387b2f5 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/AppUiState.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/AppUiState.kt
@@ -2,6 +2,7 @@ package net.nymtech.nymvpn.ui
import net.nymtech.nymvpn.data.domain.Settings
import net.nymtech.vpn.model.VpnClientState
+import java.time.Instant
data class AppUiState(
val loading: Boolean = true,
@@ -9,4 +10,6 @@ data class AppUiState(
val snackbarMessageConsumed: Boolean = true,
val vpnClientState: VpnClientState = VpnClientState(),
val settings: Settings = Settings(),
+ val isNonExpiredCredentialImported: Boolean = false,
+ val credentialExpiryTime: Instant? = null,
)
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/AppViewModel.kt b/app/src/main/java/net/nymtech/nymvpn/ui/AppViewModel.kt
index d56e7f6..4eef268 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/AppViewModel.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/AppViewModel.kt
@@ -59,14 +59,36 @@ constructor(
val logs = mutableStateListOf()
private val logsBuffer = mutableListOf()
+ init {
+ viewModelScope.launch(Dispatchers.IO) {
+ secretsRepository.get().credentialFlow.collect { cred ->
+ cred?.let {
+ getCredentialExpiry(it).onSuccess { expiry ->
+ setIsNonExpiredCredentialImported(true)
+ setCredentialExpiry(expiry)
+ }.onFailure {
+ setIsNonExpiredCredentialImported(false)
+ }
+ }
+ }
+ }
+ }
+
val uiState =
- combine(_uiState, settingsRepository.settingsFlow, vpnClient.get().stateFlow) { state, settings, vpnState ->
+ combine(
+ _uiState,
+ settingsRepository.settingsFlow,
+ vpnClient.get().stateFlow,
+ secretsRepository.get().credentialFlow,
+ ) { state, settings, vpnState, cred ->
AppUiState(
false,
state.snackbarMessage,
state.snackbarMessageConsumed,
vpnState,
settings,
+ isNonExpiredCredentialImported = state.isNonExpiredCredentialImported,
+ credentialExpiryTime = state.credentialExpiryTime,
)
}.stateIn(
viewModelScope,
@@ -102,27 +124,45 @@ constructor(
}
}
+ private fun setCredentialExpiry(instant: Instant) {
+ _uiState.update {
+ it.copy(
+ credentialExpiryTime = instant,
+ )
+ }
+ }
+
+ private fun setIsNonExpiredCredentialImported(value: Boolean) {
+ _uiState.update {
+ it.copy(
+ isNonExpiredCredentialImported = value,
+ )
+ }
+ }
+
fun clearLogs() {
logs.clear()
logsBuffer.clear()
LogcatHelper.clear()
}
- suspend fun onValidCredentialCheck(): Result {
+ suspend fun onValidCredentialCheck(): Result {
return withContext(viewModelScope.coroutineContext + Dispatchers.IO) {
val credential = secretsRepository.get().getCredential()
if (credential != null) {
- vpnClient.get().validateCredential(credential).onFailure {
- return@withContext Result.failure(NymVpnExceptions.InvalidCredentialException())
- }.onSuccess {
- return@withContext Result.success(Unit)
- }
+ getCredentialExpiry(credential)
} else {
Result.failure(NymVpnExceptions.MissingCredentialException())
}
}
}
+ private suspend fun getCredentialExpiry(credential: String): Result {
+ return vpnClient.get().validateCredential(credential).onFailure {
+ return Result.failure(NymVpnExceptions.InvalidCredentialException())
+ }
+ }
+
fun saveLogsToFile(context: Context) {
val fileName = "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.txt"
val content = logs.joinToString(separator = "\n")
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/MainActivity.kt b/app/src/main/java/net/nymtech/nymvpn/ui/MainActivity.kt
index 1f7d785..b9c52b3 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/MainActivity.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/MainActivity.kt
@@ -166,7 +166,7 @@ class MainActivity : ComponentActivity() {
appViewModel,
)
}
- composable(NavItem.Settings.Account.route) { AccountScreen(appViewModel) }
+ composable(NavItem.Settings.Account.route) { AccountScreen(appViewModel, uiState, navController) }
composable(NavItem.Settings.Legal.Licenses.route) {
LicensesScreen(
appViewModel,
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/ShortcutActivity.kt b/app/src/main/java/net/nymtech/nymvpn/ui/ShortcutActivity.kt
index 1df3f34..a359ff2 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/ShortcutActivity.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/ShortcutActivity.kt
@@ -3,9 +3,10 @@ package net.nymtech.nymvpn.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
-import net.nymtech.nymvpn.NymVpn
import net.nymtech.nymvpn.data.SettingsRepository
import net.nymtech.nymvpn.service.vpn.VpnManager
import net.nymtech.vpn.util.Action
@@ -21,9 +22,11 @@ class ShortcutActivity : ComponentActivity() {
@Inject
lateinit var settingsRepository: SettingsRepository
+ private val scope = CoroutineScope(Dispatchers.Main)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- NymVpn.applicationScope.launch(Dispatchers.IO) {
+ scope.launch(Dispatchers.IO) {
if (settingsRepository.isApplicationShortcutsEnabled()) {
when (intent.action) {
Action.START.name -> {
@@ -41,4 +44,9 @@ class ShortcutActivity : ComponentActivity() {
}
finish()
}
+
+ override fun onDestroy() {
+ super.onDestroy()
+ scope.cancel()
+ }
}
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/SplashActivity.kt b/app/src/main/java/net/nymtech/nymvpn/ui/SplashActivity.kt
index 0bf1286..48e50a8 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/SplashActivity.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/SplashActivity.kt
@@ -15,6 +15,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
import net.nymtech.nymvpn.BuildConfig
import net.nymtech.nymvpn.NymVpn
import net.nymtech.nymvpn.data.SettingsRepository
@@ -44,7 +45,9 @@ class SplashActivity : ComponentActivity() {
// init data
settingsRepository.init()
- NymVpn.applicationScope.launch(Dispatchers.IO) {
+ configureSentry()
+
+ withTimeout(3000) {
listOf(
async {
Timber.d("Updating exit country cache")
@@ -56,22 +59,9 @@ class SplashActivity : ComponentActivity() {
countryCacheService.updateEntryCountriesCache()
Timber.d("Entry countries updated")
},
-// async {
-// //TODO disable this, needs rework
-// Timber.d("Updating low latency country cache")
-// countryCacheService.updateLowLatencyEntryCountryCache()
-// val lowLatencyEntryCountry = gatewayRepository.getLowLatencyEntryCountry()
-// val currentEntry = settingsRepository.getFirstHopCountry()
-// if(currentEntry.isLowLatency && lowLatencyEntryCountry != null) {
-// settingsRepository.setFirstHopCountry(lowLatencyEntryCountry)
-// }
-// Timber.d("Low latency country updated")
-// },
).awaitAll()
}
- configureSentry()
-
val isAnalyticsShown = settingsRepository.isAnalyticsShown()
val intent = Intent(this@SplashActivity, MainActivity::class.java).apply {
@@ -85,7 +75,7 @@ class SplashActivity : ComponentActivity() {
private suspend fun configureSentry() {
if (settingsRepository.isErrorReportingEnabled()) {
- SentryAndroid.init(this@SplashActivity) { options ->
+ SentryAndroid.init(NymVpn.instance) { options ->
options.enableTracing = true
options.enableAllAutoBreadcrumbs(true)
options.isEnableUserInteractionTracing = true
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/common/buttons/MainStyledButton.kt b/app/src/main/java/net/nymtech/nymvpn/ui/common/buttons/MainStyledButton.kt
index e088cdb..b6e5de1 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/common/buttons/MainStyledButton.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/common/buttons/MainStyledButton.kt
@@ -1,5 +1,7 @@
package net.nymtech.nymvpn.ui.common.buttons
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
@@ -26,10 +28,11 @@ fun MainStyledButton(
ButtonDefaults.buttonColors(
containerColor = color,
),
+ contentPadding = PaddingValues(),
modifier =
Modifier
.height(56.dp.scaledHeight())
- .fillMaxWidth().testTag(testTag ?: ""),
+ .fillMaxWidth().testTag(testTag ?: "").defaultMinSize(1.dp, 1.dp),
shape =
ShapeDefaults.Small,
) {
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/SettingsScreen.kt
index a12c7e6..4746f49 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/SettingsScreen.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/SettingsScreen.kt
@@ -10,6 +10,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccountCircle
+import androidx.compose.material.icons.outlined.AdminPanelSettings
import androidx.compose.material.icons.outlined.AppShortcut
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material3.MaterialTheme
@@ -39,6 +41,7 @@ import net.nymtech.nymvpn.ui.common.buttons.ScaledSwitch
import net.nymtech.nymvpn.ui.common.buttons.surface.SelectionItem
import net.nymtech.nymvpn.ui.common.buttons.surface.SurfaceSelectionGroupButton
import net.nymtech.nymvpn.ui.theme.CustomTypography
+import net.nymtech.nymvpn.util.durationFromNow
import net.nymtech.nymvpn.util.scaledHeight
import net.nymtech.nymvpn.util.scaledWidth
import net.nymtech.vpn.model.VpnState
@@ -64,41 +67,50 @@ fun SettingsScreen(
.padding(top = 24.dp)
.padding(horizontal = 24.dp.scaledWidth()),
) {
-// if (!appUiState.loggedIn) {
- MainStyledButton(
- onClick = { navController.navigate(NavItem.Settings.Credential.route) },
- content = {
- Text(
- stringResource(id = R.string.add_cred_to_connect),
- style = CustomTypography.labelHuge,
+ if (!appUiState.isNonExpiredCredentialImported) {
+ MainStyledButton(
+ onClick = { navController.navigate(NavItem.Settings.Credential.route) },
+ content = {
+ Text(
+ stringResource(id = R.string.add_cred_to_connect),
+ style = CustomTypography.labelHuge,
+ )
+ },
+ color = MaterialTheme.colorScheme.primary,
+ )
+ } else {
+ appUiState.credentialExpiryTime?.let {
+ val credentialDuration = it.durationFromNow()
+ val days = credentialDuration.toDaysPart()
+ val hours = credentialDuration.toHoursPart()
+ val accountDescription =
+ buildAnnotatedString {
+ if (days != 0L) {
+ append(days.toString())
+ append(" ")
+ append(if (days != 1L) stringResource(id = R.string.days) else stringResource(id = R.string.day))
+ } else {
+ append(hours.toString())
+ append(" ")
+ append(if (hours != 1) stringResource(id = R.string.hours) else stringResource(id = R.string.hour))
+ }
+ append(" ")
+ append(stringResource(id = R.string.left))
+ }
+ SurfaceSelectionGroupButton(
+ listOf(
+ SelectionItem(
+ Icons.Filled.AccountCircle,
+ onClick = {
+ navController.navigate(NavItem.Settings.Account.route)
+ },
+ title = { Text(stringResource(R.string.credential), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
+ description = { Text(accountDescription.text, style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.outline)) },
+ ),
+ ),
)
- },
- color = MaterialTheme.colorScheme.primary,
- )
-// } else {
- // TODO disable account for now
-
-// val accountDescription =
-// buildAnnotatedString {
-// append("31")
-// append(" ")
-// append(stringResource(id = R.string.of))
-// append(" ")
-// append("31")
-// append(" ")
-// append(stringResource(id = R.string.days_left))
-// }
-// SurfaceSelectionGroupButton(
-// listOf(
-// SelectionItem(
-// Icons.Filled.AccountCircle,
-// onClick = { navController.navigate(NavItem.Settings.Account.route) },
-// title = { Text(stringResource(R.string.credential), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface))},
-// description = { Text(accountDescription.text, style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.outline))},
-// ),
-// ),
-// )
-// }
+ }
+ }
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
@@ -121,6 +133,13 @@ fun SettingsScreen(
)
},
),
+ SelectionItem(
+ Icons.Outlined.AdminPanelSettings,
+ title = { Text(stringResource(R.string.kill_switch), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
+ onClick = {
+ viewModel.onKillSwitchSelected(context)
+ },
+ ),
SelectionItem(
Icons.Outlined.AppShortcut,
{
@@ -141,15 +160,6 @@ fun SettingsScreen(
)
},
),
- SelectionItem(
- ImageVector.vectorResource(R.drawable.contrast),
- title = { Text(stringResource(R.string.display_theme), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
- onClick = { navController.navigate(NavItem.Settings.Display.route) },
- ),
- ),
- )
- SurfaceSelectionGroupButton(
- listOf(
SelectionItem(
ImageVector.vectorResource(R.drawable.two),
{
@@ -175,8 +185,20 @@ fun SettingsScreen(
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.outline),
)
},
- {},
),
+ ),
+ )
+ SurfaceSelectionGroupButton(
+ listOf(
+ SelectionItem(
+ ImageVector.vectorResource(R.drawable.contrast),
+ title = { Text(stringResource(R.string.display_theme), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
+ onClick = { navController.navigate(NavItem.Settings.Display.route) },
+ ),
+ ),
+ )
+ SurfaceSelectionGroupButton(
+ listOf(
SelectionItem(
ImageVector.vectorResource(R.drawable.logs),
title = { Text(stringResource(R.string.logs), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/SettingsViewModel.kt
index 058b9c2..e9a3482 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/SettingsViewModel.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/SettingsViewModel.kt
@@ -1,5 +1,8 @@
package net.nymtech.nymvpn.ui.screens.settings
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -33,4 +36,11 @@ constructor(
fun onAppShortcutsSelected(selected: Boolean) = viewModelScope.launch {
settingsRepository.setApplicationShortcuts(selected)
}
+
+ fun onKillSwitchSelected(context: Context) {
+ val intent = Intent("android.net.vpn.SETTINGS").apply {
+ setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
}
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/account/AccountScreen.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/account/AccountScreen.kt
index 8256024..aef935e 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/account/AccountScreen.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/account/AccountScreen.kt
@@ -31,22 +31,28 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavController
import net.nymtech.nymvpn.R
+import net.nymtech.nymvpn.ui.AppUiState
import net.nymtech.nymvpn.ui.AppViewModel
+import net.nymtech.nymvpn.ui.NavItem
import net.nymtech.nymvpn.ui.common.buttons.MainStyledButton
import net.nymtech.nymvpn.ui.common.buttons.surface.SelectionItem
import net.nymtech.nymvpn.ui.common.buttons.surface.SurfaceSelectionGroupButton
import net.nymtech.nymvpn.ui.common.labels.GroupLabel
import net.nymtech.nymvpn.ui.theme.CustomTypography
+import net.nymtech.nymvpn.util.durationFromNow
import net.nymtech.nymvpn.util.scaledHeight
import net.nymtech.nymvpn.util.scaledWidth
@Composable
-fun AccountScreen(appViewModel: AppViewModel, viewModel: AccountViewModel = hiltViewModel()) {
+fun AccountScreen(appViewModel: AppViewModel, appUiState: AppUiState, navController: NavController, viewModel: AccountViewModel = hiltViewModel()) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val devicesDisabled = true
+
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
@@ -67,40 +73,44 @@ fun AccountScreen(appViewModel: AppViewModel, viewModel: AccountViewModel = hilt
horizontalAlignment = Alignment.Start,
modifier = Modifier.fillMaxSize(),
) {
- // TODO get real values from server
- val daysLeft =
- buildAnnotatedString {
- append(uiState.subscriptionDaysRemaining.toString())
- append(" ")
- append(stringResource(id = R.string.of))
- append(" ")
- append(uiState.subscriptionTotalDays.toString())
- append(" ")
- append(stringResource(id = R.string.days_left))
- }
- Text(
- daysLeft.text,
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.onSurface,
- )
- LinearProgressIndicator(
- modifier =
- Modifier
- .fillMaxWidth(),
- progress = {
- if (uiState.subscriptionTotalDays == 0) {
- 0f
- } else {
- uiState.subscriptionDaysRemaining.toFloat() / uiState.subscriptionTotalDays
+ appUiState.credentialExpiryTime?.let {
+ val credentialDuration = it.durationFromNow()
+ val days = credentialDuration.toDaysPart()
+ val hours = credentialDuration.toHoursPart()
+ val durationLeft =
+ buildAnnotatedString {
+ append(days.toString())
+ append(" ")
+ append(if (days != 1L) stringResource(id = R.string.days) else stringResource(id = R.string.day))
+ append(", ")
+ append(hours.toString())
+ append(" ")
+ append(if (hours != 1) stringResource(id = R.string.hours) else stringResource(id = R.string.hour))
+ append(" ")
+ append(stringResource(id = R.string.left))
}
- },
- )
+ Text(
+ durationLeft.text,
+ style = CustomTypography.labelHuge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ LinearProgressIndicator(
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ progress = {
+ // TODO need to think about this more, setting to full for now
+ 1f
+ },
+ )
+ }
+
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
- .heightIn(min = 40.dp)
+ .heightIn(min = 40.dp.scaledHeight())
.fillMaxWidth(),
) {
Text(
@@ -111,7 +121,9 @@ fun AccountScreen(appViewModel: AppViewModel, viewModel: AccountViewModel = hilt
)
Box(modifier = Modifier.width(100.dp.scaledWidth())) {
MainStyledButton(
- onClick = { appViewModel.showFeatureInProgressMessage(context) },
+ onClick = {
+ navController.navigate(NavItem.Settings.Credential.route)
+ },
content = {
Text(
stringResource(id = R.string.top_up),
@@ -123,48 +135,50 @@ fun AccountScreen(appViewModel: AppViewModel, viewModel: AccountViewModel = hilt
}
}
}
- Column(
- verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top),
- modifier = Modifier.fillMaxSize(),
- ) {
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- modifier =
- Modifier
- .height(48.dp)
- .fillMaxWidth(),
+ if (!devicesDisabled) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top),
+ modifier = Modifier.fillMaxSize(),
) {
- GroupLabel(title = stringResource(R.string.devices))
- IconButton(onClick = {
- appViewModel.showFeatureInProgressMessage(context)
- }, modifier = Modifier.padding(start = 24.dp)) {
- Icon(
- Icons.Filled.Add,
- Icons.Filled.Add.name,
- tint = MaterialTheme.colorScheme.onSurface,
- )
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier =
+ Modifier
+ .height(48.dp)
+ .fillMaxWidth(),
+ ) {
+ GroupLabel(title = stringResource(R.string.devices))
+ IconButton(onClick = {
+ appViewModel.showFeatureInProgressMessage(context)
+ }, modifier = Modifier.padding(start = 24.dp)) {
+ Icon(
+ Icons.Filled.Add,
+ Icons.Filled.Add.name,
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
}
+ SurfaceSelectionGroupButton(
+ items =
+ uiState.devices.map {
+ SelectionItem(
+ ImageVector.vectorResource(it.type.icon()),
+ trailing = {
+ IconButton(
+ onClick = { /*TODO handle item delete from authorized*/ },
+ ) {
+ Icon(Icons.Filled.Clear, Icons.Filled.Clear.name)
+ }
+ },
+ title = { Text(it.name, style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
+ description = {
+ Text(it.type.formattedName().asString(context), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.outline))
+ },
+ )
+ },
+ )
}
- SurfaceSelectionGroupButton(
- items =
- uiState.devices.map {
- SelectionItem(
- ImageVector.vectorResource(it.type.icon()),
- trailing = {
- IconButton(
- onClick = { /*TODO handle item delete from authorized*/ },
- ) {
- Icon(Icons.Filled.Clear, Icons.Filled.Clear.name)
- }
- },
- title = { Text(it.name, style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
- description = {
- Text(it.type.formattedName().asString(context), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.outline))
- },
- )
- },
- )
}
}
}
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/account/AccountUiState.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/account/AccountUiState.kt
index 2a3e4b3..020442a 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/account/AccountUiState.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/account/AccountUiState.kt
@@ -3,8 +3,5 @@ package net.nymtech.nymvpn.ui.screens.settings.account
import net.nymtech.nymvpn.ui.screens.settings.account.model.Devices
data class AccountUiState(
- val loading: Boolean = true,
val devices: Devices = emptyList(),
- val subscriptionDaysRemaining: Int = 0,
- val subscriptionTotalDays: Int = 0,
)
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/account/AccountViewModel.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/account/AccountViewModel.kt
index 752f45a..f8a1a6b 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/account/AccountViewModel.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/account/AccountViewModel.kt
@@ -18,12 +18,8 @@ constructor(
) : ViewModel() {
val uiState =
settingsRepository.settingsFlow.map {
- // TODO mocked for now
AccountUiState(
- loading = false,
devices = emptyList(),
- subscriptionDaysRemaining = 31,
- subscriptionTotalDays = 31,
)
}.stateIn(
viewModelScope,
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/credential/CredentialScreen.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/credential/CredentialScreen.kt
index a95e46d..c95cbb8 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/credential/CredentialScreen.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/credential/CredentialScreen.kt
@@ -1,17 +1,23 @@
package net.nymtech.nymvpn.ui.screens.settings.credential
+import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.Image
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
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.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.QrCodeScanner
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -31,6 +37,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
+import com.journeyapps.barcodescanner.ScanContract
+import com.journeyapps.barcodescanner.ScanOptions
import kotlinx.coroutines.launch
import net.nymtech.nymvpn.R
import net.nymtech.nymvpn.ui.AppViewModel
@@ -39,6 +47,7 @@ import net.nymtech.nymvpn.ui.common.buttons.MainStyledButton
import net.nymtech.nymvpn.ui.common.functions.rememberImeState
import net.nymtech.nymvpn.ui.common.textbox.CustomTextField
import net.nymtech.nymvpn.ui.theme.CustomTypography
+import net.nymtech.nymvpn.ui.theme.iconSize
import net.nymtech.nymvpn.util.Constants
import net.nymtech.nymvpn.util.navigateNoBack
import net.nymtech.nymvpn.util.scaledHeight
@@ -46,7 +55,7 @@ import net.nymtech.nymvpn.util.scaledWidth
@Composable
fun CredentialScreen(navController: NavController, appViewModel: AppViewModel, viewModel: CredentialViewModel = hiltViewModel()) {
- var recoveryPhrase by remember {
+ var credential by remember {
mutableStateOf("")
}
@@ -60,12 +69,46 @@ fun CredentialScreen(navController: NavController, appViewModel: AppViewModel, v
val imeState = rememberImeState()
val scrollState = rememberScrollState()
+ fun onAddCredential() {
+ scope.launch {
+ viewModel.onImportCredential(credential).onSuccess { _ ->
+ appViewModel.showSnackbarMessage(context.getString(R.string.credential_successful))
+ navController.navigateNoBack(NavItem.Main.route)
+ }.onFailure {
+ isCredentialError = true
+ }
+ }
+ }
+
+ val scanLauncher =
+ rememberLauncherForActivityResult(
+ contract = ScanContract(),
+ onResult = {
+ if (it.contents != null) {
+ credential = ""
+ credential = it.contents
+ onAddCredential()
+ }
+ },
+ )
+
LaunchedEffect(imeState.value) {
if (imeState.value) {
scrollState.animateScrollTo(scrollState.viewportSize)
}
}
+ fun launchQrScanner() {
+ val scanOptions = ScanOptions()
+ scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
+ scanOptions.setOrientationLocked(true)
+ scanOptions.setPrompt(
+ context.getString(R.string.scan_nym_vpn_credential),
+ )
+ scanOptions.setBeepEnabled(false)
+ scanLauncher.launch(scanOptions)
+ }
+
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(40.dp.scaledHeight(), Alignment.Bottom),
@@ -118,10 +161,10 @@ fun CredentialScreen(navController: NavController, appViewModel: AppViewModel, v
verticalArrangement = Arrangement.spacedBy(32.dp.scaledHeight(), Alignment.Top),
) {
CustomTextField(
- value = recoveryPhrase,
+ value = credential,
onValueChange = {
if (isCredentialError) isCredentialError = false
- recoveryPhrase = it
+ credential = it
},
modifier = Modifier
.width(358.dp.scaledWidth())
@@ -141,31 +184,39 @@ fun CredentialScreen(navController: NavController, appViewModel: AppViewModel, v
color = MaterialTheme.colorScheme.onSurface,
),
)
- Box(
- modifier =
- Modifier
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp.scaledWidth(), Alignment.CenterHorizontally),
+ modifier = Modifier
+ .fillMaxWidth()
.padding(bottom = 24.dp.scaledHeight()),
) {
- MainStyledButton(
- Constants.LOGIN_TEST_TAG,
- onClick = {
- scope.launch {
- viewModel.onImportCredential(recoveryPhrase).onSuccess {
- appViewModel.showSnackbarMessage(context.getString(R.string.credential_successful))
- navController.navigateNoBack(NavItem.Main.route)
- }.onFailure {
- isCredentialError = true
- }
- }
- },
- content = {
- Text(
- stringResource(id = R.string.add_credential),
- style = CustomTypography.labelHuge,
- )
- },
- color = MaterialTheme.colorScheme.primary,
- )
+ Box(modifier = Modifier.width(286.dp.scaledWidth())) {
+ MainStyledButton(
+ Constants.LOGIN_TEST_TAG,
+ onClick = {
+ onAddCredential()
+ },
+ content = {
+ Text(
+ stringResource(id = R.string.add_credential),
+ style = CustomTypography.labelHuge,
+ )
+ },
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ Box(modifier = Modifier.width(56.dp.scaledWidth())) {
+ MainStyledButton(
+ onClick = {
+ launchQrScanner()
+ },
+ content = {
+ val icon = Icons.Outlined.QrCodeScanner
+ Icon(icon, icon.name, modifier = Modifier.size(iconSize.scaledWidth()))
+ },
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
}
}
}
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/credential/CredentialViewModel.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/credential/CredentialViewModel.kt
index 3aad7a6..d93ee90 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/credential/CredentialViewModel.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/credential/CredentialViewModel.kt
@@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.nymtech.nymvpn.data.SecretsRepository
import net.nymtech.vpn.VpnClient
+import java.time.Instant
import javax.inject.Inject
import javax.inject.Provider
@@ -17,7 +18,7 @@ constructor(
private val secretsRepository: Provider,
private val vpnClient: Provider,
) : ViewModel() {
- suspend fun onImportCredential(credential: String): Result {
+ suspend fun onImportCredential(credential: String): Result {
val trimmedCred = credential.trim()
return withContext(viewModelScope.coroutineContext + Dispatchers.IO) {
vpnClient.get().validateCredential(trimmedCred).onSuccess {
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/feedback/FeedbackScreen.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/feedback/FeedbackScreen.kt
index 44971a7..f56ca31 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/feedback/FeedbackScreen.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/feedback/FeedbackScreen.kt
@@ -90,9 +90,8 @@ fun FeedbackScreen(appViewModel: AppViewModel) {
SelectionItem(
leadingIcon = ImageVector.vectorResource(R.drawable.send),
title = { Text(stringResource(R.string.send_feedback), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
-
onClick = {
- appViewModel.launchEmail(context)
+ appViewModel.openWebPage(context.getString(R.string.contact_url), context)
},
),
),
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/legal/licenses/LicensesScreen.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/legal/licenses/LicensesScreen.kt
index f33fd6e..f8b5c4c 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/legal/licenses/LicensesScreen.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/legal/licenses/LicensesScreen.kt
@@ -11,10 +11,12 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -30,6 +32,13 @@ fun LicensesScreen(appViewModel: AppViewModel, viewModel: LicensesViewModel = hi
val context = LocalContext.current
val licenses by viewModel.licenses.collectAsStateWithLifecycle()
+ val licenseComparator = compareBy { it.name }
+
+ val sortedLicenses =
+ remember(licenses, licenseComparator) {
+ licenses.sortedWith(licenseComparator)
+ }
+
LaunchedEffect(Unit) {
viewModel.loadLicensesFromAssets(context)
}
@@ -45,24 +54,18 @@ fun LicensesScreen(appViewModel: AppViewModel, viewModel: LicensesViewModel = hi
item {
Row(modifier = Modifier.padding(bottom = 24.dp.scaledHeight())) {}
}
- items(licenses) { it ->
+ items(sortedLicenses) { it ->
SurfaceSelectionGroupButton(
items =
listOf(
SelectionItem(
- // TODO refactor
title = {
Text(
- if (it.name != null && it.name.length > 32) {
- it.name.substring(
- 0,
- 29,
- ).plus("...")
- } else {
- it.name
- ?: stringResource(id = R.string.unknown)
- },
+ it.name
+ ?: stringResource(id = R.string.unknown),
style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
)
},
description = {
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/support/SupportScreen.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/support/SupportScreen.kt
index 126af35..b9182f4 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/support/SupportScreen.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/support/SupportScreen.kt
@@ -46,6 +46,15 @@ fun SupportScreen(appViewModel: AppViewModel) {
),
),
)
+ SurfaceSelectionGroupButton(
+ listOf(
+ SelectionItem(
+ leadingIcon = ImageVector.vectorResource(R.drawable.send),
+ title = { Text(stringResource(R.string.contact_support), style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface)) },
+ onClick = { appViewModel.openWebPage(context.getString(R.string.contact_url), context) },
+ ),
+ ),
+ )
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/theme/Type.kt b/app/src/main/java/net/nymtech/nymvpn/ui/theme/Type.kt
index 1e63664..93bc0e2 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/theme/Type.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/theme/Type.kt
@@ -13,7 +13,6 @@ val Typography =
Typography(
bodyLarge =
TextStyle(
- fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp.scaled(),
lineHeight = 24.sp.scaled(),
diff --git a/app/src/main/java/net/nymtech/nymvpn/util/Constants.kt b/app/src/main/java/net/nymtech/nymvpn/util/Constants.kt
index 5ac54f2..728a0c6 100644
--- a/app/src/main/java/net/nymtech/nymvpn/util/Constants.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/util/Constants.kt
@@ -20,4 +20,6 @@ object Constants {
const val CONNECT_TEST_TAG = "connectTag"
const val LOGIN_TEST_TAG = "loginTag"
const val DISCONNECT_TEST_TAG = "disconnectTag"
+
+ const val VPN_SETTINGS_PACKAGE = "android.net.vpn.SETTINGS"
}
diff --git a/app/src/main/java/net/nymtech/nymvpn/util/Extensions.kt b/app/src/main/java/net/nymtech/nymvpn/util/Extensions.kt
index a988073..4f04934 100644
--- a/app/src/main/java/net/nymtech/nymvpn/util/Extensions.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/util/Extensions.kt
@@ -12,6 +12,8 @@ import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import net.nymtech.nymvpn.NymVpn
+import java.time.Duration
+import java.time.Instant
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.coroutineContext
@@ -56,3 +58,7 @@ fun NavController.navigateNoBack(route: String) {
popUpTo(0)
}
}
+
+fun Instant.durationFromNow(): Duration {
+ return Duration.between(Instant.now(), this)
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 32e8f98..7b73dca 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -53,6 +53,7 @@
https://matrix.to/#/%23NymVPN:nymtech.chat
https://discord.com/invite/nym
https://support.nymvpn.com
+ https://support.nymvpn.com/hc/en-us/requests/new
support@nymvpn.com
https://github.com/nymtech/nymvpn-android/issues/new/choose
NymVPN Support
@@ -67,8 +68,7 @@
Invalid credential
An unknown error occurred
Credential
- of
- days left
+ left
Top up your credential
Top up
Devices
@@ -124,4 +124,11 @@
"Selected gateway has a bad peer certificate. Please try again to connect to a different gateway. "
Gateway missing hostname address. Please try again to connect to a different gateway.
VPN has come to an unexpected halt. Please try connecting again.
+ Kill switch
+ Contact support
+ hours
+ hour
+ days
+ day
+ Scan NymVPN Credential QR
diff --git a/buildSrc/src/main/kotlin/Constants.kt b/buildSrc/src/main/kotlin/Constants.kt
index 5d6ed4c..df03c20 100644
--- a/buildSrc/src/main/kotlin/Constants.kt
+++ b/buildSrc/src/main/kotlin/Constants.kt
@@ -1,8 +1,8 @@
import org.gradle.api.JavaVersion
object Constants {
- const val VERSION_NAME = "v1.0.2"
- const val VERSION_CODE = 10200
+ const val VERSION_NAME = "v1.0.3"
+ const val VERSION_CODE = 10300
const val TARGET_SDK = 34
const val COMPILE_SDK = 34
const val MIN_SDK = 24
@@ -32,6 +32,7 @@ object Constants {
//licensee
val allowedLicenses = listOf("MIT", "Apache-2.0", "BSD-3-Clause")
const val ANDROID_TERMS_URL = "https://developer.android.com/studio/terms.html"
+ const val XZING_LICENSE_URL: String = "https://github.com/journeyapps/zxing-android-embedded/blob/master/COPYING"
//build config
const val SENTRY_DSN = "SENTRY_DSN"
diff --git a/fastlane/metadata/android/en-US/changelogs/10300.txt b/fastlane/metadata/android/en-US/changelogs/10300.txt
new file mode 100644
index 0000000..c2ca0bd
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/10300.txt
@@ -0,0 +1,5 @@
+What's new:
+- Credential expiry time
+- QR code scanning for credential
+- Kill switch link
+- UI enhancements
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5756871..36f41e9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,6 +1,6 @@
[versions]
accompanist = "0.34.0"
-agp = "8.4.0"
+agp = "8.4.1"
coreSplashscreen = "1.0.1"
detektRulesCompose = "1.3.0"
javaClient = "9.2.2"
@@ -26,12 +26,13 @@ kotlinx-serialization-json = "1.6.3"
kotlinxCoroutinesCore = "1.8.0"
uiautomator = "2.3.0"
window = "1.2.0"
-windowCoreAndroid = "1.3.0-beta02"
+windowCoreAndroid = "1.3.0-rc01"
desugar = "2.0.4"
moshi = "1.15.1"
moshiKotlin = "1.15.1"
moshiKotlinCodegen = "1.15.1"
converterMoshi = "2.11.0"
+zxingAndroidEmbedded = "4.3.0"
gradlePlugins-kotlinxSerialization = "1.9.23"
gradlePlugins-licensee = "1.7.0"
@@ -78,6 +79,9 @@ moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "mosh
moshi-kotlin-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshiKotlinCodegen" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "converterMoshi" }
+#barcode
+zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }
+
#util
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index cfb54ec..4a944fb 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Mon Jan 08 05:43:40 EST 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/nym_vpn_client/src/main/java/net/nymtech/vpn/NymVpnClient.kt b/nym_vpn_client/src/main/java/net/nymtech/vpn/NymVpnClient.kt
index a2e7171..6719126 100644
--- a/nym_vpn_client/src/main/java/net/nymtech/vpn/NymVpnClient.kt
+++ b/nym_vpn_client/src/main/java/net/nymtech/vpn/NymVpnClient.kt
@@ -33,6 +33,7 @@ import nym_vpn_lib.VpnConfig
import nym_vpn_lib.checkCredential
import nym_vpn_lib.runVpn
import timber.log.Timber
+import java.time.Instant
import kotlin.coroutines.coroutineContext
object NymVpnClient {
@@ -78,10 +79,10 @@ object NymVpnClient {
private val _state = MutableStateFlow(VpnClientState())
override val stateFlow: Flow = _state.asStateFlow()
- override fun validateCredential(credential: String): Result {
+ override fun validateCredential(credential: String): Result {
return try {
- checkCredential(credential)
- Result.success(Unit)
+ val expiry = checkCredential(credential)
+ Result.success(expiry)
} catch (_: FfiException) {
Result.failure(InvalidCredentialException("Credential invalid or expired"))
}
@@ -95,7 +96,7 @@ object NymVpnClient {
clearErrorStatus()
with(CoroutineScope(coroutineContext)) {
launch {
- collectLogStatus(context)
+ collectLogStatus()
}
launch {
startConnectionTimer()
@@ -168,7 +169,7 @@ object NymVpnClient {
else -> false
}
- internal fun connect(context: Context) {
+ internal fun connect() {
try {
runVpn(
VpnConfig(
@@ -192,7 +193,7 @@ object NymVpnClient {
clearStatisticState()
}
- private suspend fun collectLogStatus(context: Context) {
+ private suspend fun collectLogStatus() {
callbackFlow {
LogcatHelper.logs {
if (it.level != LogLevel.DEBUG) {
diff --git a/nym_vpn_client/src/main/java/net/nymtech/vpn/NymVpnService.kt b/nym_vpn_client/src/main/java/net/nymtech/vpn/NymVpnService.kt
index 4c573c8..5a0e816 100644
--- a/nym_vpn_client/src/main/java/net/nymtech/vpn/NymVpnService.kt
+++ b/nym_vpn_client/src/main/java/net/nymtech/vpn/NymVpnService.kt
@@ -90,7 +90,7 @@ class NymVpnService : VpnService() {
NymVpnClient.NymVpn.setVpnState(VpnState.Connecting.InitializingClient)
val logLevel = if (BuildConfig.DEBUG) "debug" else "info"
initVPN(this@NymVpnService, logLevel)
- NymVpnClient.NymVpn.connect(this@NymVpnService)
+ NymVpnClient.NymVpn.connect()
}
}
}
diff --git a/nym_vpn_client/src/main/java/net/nymtech/vpn/VpnClient.kt b/nym_vpn_client/src/main/java/net/nymtech/vpn/VpnClient.kt
index 9ed7812..f40d53c 100644
--- a/nym_vpn_client/src/main/java/net/nymtech/vpn/VpnClient.kt
+++ b/nym_vpn_client/src/main/java/net/nymtech/vpn/VpnClient.kt
@@ -8,6 +8,7 @@ import net.nymtech.vpn.model.VpnMode
import net.nymtech.vpn.util.InvalidCredentialException
import nym_vpn_lib.EntryPoint
import nym_vpn_lib.ExitPoint
+import java.time.Instant
interface VpnClient {
@@ -15,7 +16,7 @@ interface VpnClient {
var exitPoint: ExitPoint
var mode: VpnMode
- fun validateCredential(credential: String): Result
+ fun validateCredential(credential: String): Result
@Throws(InvalidCredentialException::class)
suspend fun start(context: Context, credential: String, foreground: Boolean = false)
diff --git a/nym_vpn_client/src/main/java/net/nymtech/vpn/nym_vpn_lib/nym_vpn_lib.kt b/nym_vpn_client/src/main/java/net/nymtech/vpn/nym_vpn_lib/nym_vpn_lib.kt
index 27eef2d..3b83e15 100644
--- a/nym_vpn_client/src/main/java/net/nymtech/vpn/nym_vpn_lib/nym_vpn_lib.kt
+++ b/nym_vpn_client/src/main/java/net/nymtech/vpn/nym_vpn_lib/nym_vpn_lib.kt
@@ -720,7 +720,7 @@ internal interface UniffiLib : Library {
}
fun uniffi_nym_vpn_lib_fn_func_checkcredential(`credential`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus,
- ): Unit
+ ): RustBuffer.ByValue
fun uniffi_nym_vpn_lib_fn_func_getgatewaycountries(`apiUrl`: RustBuffer.ByValue,`explorerUrl`: RustBuffer.ByValue,`harbourMasterUrl`: RustBuffer.ByValue,`exitOnly`: Byte,uniffi_out_err: UniffiRustCallStatus,
): RustBuffer.ByValue
fun uniffi_nym_vpn_lib_fn_func_getlowlatencyentrycountry(`apiUrl`: RustBuffer.ByValue,`explorerUrl`: RustBuffer.ByValue,`harbourMasterUrl`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus,
@@ -872,7 +872,7 @@ private fun uniffiCheckContractApiVersion(lib: UniffiLib) {
@Suppress("UNUSED_PARAMETER")
private fun uniffiCheckApiChecksums(lib: UniffiLib) {
- if (lib.uniffi_nym_vpn_lib_checksum_func_checkcredential() != 37960.toShort()) {
+ if (lib.uniffi_nym_vpn_lib_checksum_func_checkcredential() != 44396.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
if (lib.uniffi_nym_vpn_lib_checksum_func_getgatewaycountries() != 4475.toShort()) {
@@ -1025,6 +1025,46 @@ public object FfiConverterString: FfiConverter {
}
+public object FfiConverterTimestamp: FfiConverterRustBuffer {
+ override fun read(buf: ByteBuffer): java.time.Instant {
+ val seconds = buf.getLong()
+ // Type mismatch (should be u32) but we check for overflow/underflow below
+ val nanoseconds = buf.getInt().toLong()
+ if (nanoseconds < 0) {
+ throw java.time.DateTimeException("Instant nanoseconds exceed minimum or maximum supported by uniffi")
+ }
+ if (seconds >= 0) {
+ return java.time.Instant.EPOCH.plus(java.time.Duration.ofSeconds(seconds, nanoseconds))
+ } else {
+ return java.time.Instant.EPOCH.minus(java.time.Duration.ofSeconds(-seconds, nanoseconds))
+ }
+ }
+
+ // 8 bytes for seconds, 4 bytes for nanoseconds
+ override fun allocationSize(value: java.time.Instant) = 12UL
+
+ override fun write(value: java.time.Instant, buf: ByteBuffer) {
+ var epochOffset = java.time.Duration.between(java.time.Instant.EPOCH, value)
+
+ var sign = 1
+ if (epochOffset.isNegative()) {
+ sign = -1
+ epochOffset = epochOffset.negated()
+ }
+
+ if (epochOffset.nano < 0) {
+ // Java docs provide guarantee that nano will always be positive, so this should be impossible
+ // See: https://docs.oracle.com/javase/8/docs/api/java/time/Instant.html
+ throw IllegalArgumentException("Invalid timestamp, nano value must be non-negative")
+ }
+
+ buf.putLong(sign * epochOffset.seconds)
+ // Type mismatch (should be u32) but since values will always be between 0 and 999,999,999 it should be OK
+ buf.putInt(epochOffset.nano)
+ }
+}
+
+
data class Location (
var `twoLetterIsoCountryCode`: kotlin.String,
@@ -1685,13 +1725,14 @@ public object FfiConverterTypeUrl: FfiConverter {
FfiConverterString.write(builtinValue, buf)
}
}
- @Throws(FfiException::class) fun `checkCredential`(`credential`: kotlin.String)
- =
+ @Throws(FfiException::class) fun `checkCredential`(`credential`: kotlin.String): java.time.Instant {
+ return FfiConverterTimestamp.lift(
uniffiRustCallWithError(FfiException) { _status ->
UniffiLib.INSTANCE.uniffi_nym_vpn_lib_fn_func_checkcredential(
FfiConverterString.lower(`credential`),_status)
}
-
+ )
+ }
@Throws(FfiException::class) fun `getGatewayCountries`(`apiUrl`: Url, `explorerUrl`: Url, `harbourMasterUrl`: Url?, `exitOnly`: kotlin.Boolean): List {
diff --git a/nym_vpn_client/src/tools/nym-vpn-client b/nym_vpn_client/src/tools/nym-vpn-client
index 7edbc75..2469197 160000
--- a/nym_vpn_client/src/tools/nym-vpn-client
+++ b/nym_vpn_client/src/tools/nym-vpn-client
@@ -1 +1 @@
-Subproject commit 7edbc753dc83dfbef2d4f4f1dd7e95a5459ab8f2
+Subproject commit 24691979d2ecbbf8f03e83aaf3f8ba43f6ef5b3f