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