Skip to content
This repository has been archived by the owner on Jul 11, 2024. It is now read-only.

Commit

Permalink
feat: credential expiry time (#55)
Browse files Browse the repository at this point in the history
Added functionality to display the currently imported credential expiry time and allow users to view a basic credential screen to top of credential.

Added a link to Android native kill switch in settings.

Added initial QR code scanning feature for adding a new credential via QR.

Improved settings UI organization.

Improved licenses screen with sorting.
  • Loading branch information
zaneschepke committed May 24, 2024
1 parent a1b2d24 commit 00b5d24
Show file tree
Hide file tree
Showing 35 changed files with 476 additions and 219 deletions.
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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)
}
5 changes: 4 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="net.nymtech.nymvpn.VPN_CONTROL" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT"/>
<!--start vpn on boot permission-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
Expand Down Expand Up @@ -35,6 +34,10 @@
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="portrait"
tools:replace="screenOrientation" />
<activity
android:name=".ui.MainActivity"
android:configChanges="orientation|keyboardHidden"
Expand Down
9 changes: 0 additions & 9 deletions app/src/main/java/net/nymtech/nymvpn/NymVpn.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import android.service.quicksettings.TileService
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import net.nymtech.nymvpn.service.tile.VpnQuickTile
import net.nymtech.nymvpn.util.actionBarSize
import net.nymtech.nymvpn.util.log.DebugTree
Expand Down Expand Up @@ -38,15 +36,8 @@ class NymVpn : Application() {
}
}

override fun onLowMemory() {
super.onLowMemory()
applicationScope.cancel("onLowMemory() called by system")
applicationScope = MainScope()
}

companion object {

var applicationScope = MainScope()
lateinit var instance: NymVpn
private set

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package net.nymtech.nymvpn.data

import kotlinx.coroutines.flow.Flow

interface SecretsRepository {
suspend fun getCredential(): String?
suspend fun saveCredential(credential: String)
val credentialFlow: Flow<String?>
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -21,3 +27,38 @@ class EncryptedPreferences(context: Context) {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}

inline fun <reified T> SharedPreferences.observeKey(key: String, default: T?): Flow<T?> {
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 <reified T> 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<String>) as T
else -> throw IllegalArgumentException("generic type not handle ${T::class.java.name}")
}
}
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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<String?> = encryptedPreferences.sharedPreferences.observeKey(CRED, null)
}
3 changes: 3 additions & 0 deletions app/src/main/java/net/nymtech/nymvpn/ui/AppUiState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ 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,
val snackbarMessage: String = "",
val snackbarMessageConsumed: Boolean = true,
val vpnClientState: VpnClientState = VpnClientState(),
val settings: Settings = Settings(),
val isNonExpiredCredentialImported: Boolean = false,
val credentialExpiryTime: Instant? = null,
)
54 changes: 47 additions & 7 deletions app/src/main/java/net/nymtech/nymvpn/ui/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,36 @@ constructor(
val logs = mutableStateListOf<LogMessage>()
private val logsBuffer = mutableListOf<LogMessage>()

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,
Expand Down Expand Up @@ -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<Unit> {
suspend fun onValidCredentialCheck(): Result<Instant> {
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<Instant> {
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")
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/net/nymtech/nymvpn/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions app/src/main/java/net/nymtech/nymvpn/ui/ShortcutActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 -> {
Expand All @@ -41,4 +44,9 @@ class ShortcutActivity : ComponentActivity() {
}
finish()
}

override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
}
20 changes: 5 additions & 15 deletions app/src/main/java/net/nymtech/nymvpn/ui/SplashActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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 {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
) {
Expand Down
Loading

0 comments on commit 00b5d24

Please sign in to comment.