diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 420e0d4..08c5801 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,27 +1,27 @@
+ xmlns:tools="http://schemas.android.com/tools">
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
+ android:screenOrientation="portrait"
+ android:windowSoftInputMode="adjustResize">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/net/nymtech/nymvpn/NymVpn.kt b/app/src/main/java/net/nymtech/nymvpn/NymVpn.kt
index 44de174..8035eec 100644
--- a/app/src/main/java/net/nymtech/nymvpn/NymVpn.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/NymVpn.kt
@@ -8,16 +8,39 @@ import android.service.quicksettings.TileService
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import dagger.hilt.android.HiltAndroidApp
+import io.sentry.Sentry
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import net.nymtech.logcathelper.LogCollect
+import net.nymtech.logcathelper.model.LogLevel
+import net.nymtech.logcathelper.model.LogMessage
+import net.nymtech.nymvpn.module.ApplicationScope
+import net.nymtech.nymvpn.module.IoDispatcher
import net.nymtech.nymvpn.service.tile.VpnQuickTile
+import net.nymtech.nymvpn.util.Constants
import net.nymtech.nymvpn.util.actionBarSize
import net.nymtech.nymvpn.util.log.DebugTree
+import net.nymtech.nymvpn.util.log.NymLibException
import net.nymtech.nymvpn.util.log.ReleaseTree
import net.nymtech.vpn.model.Environment
import timber.log.Timber
+import javax.inject.Inject
@HiltAndroidApp
class NymVpn : Application() {
+ @Inject
+ @ApplicationScope
+ lateinit var applicationScope: CoroutineScope
+
+ @Inject
+ @IoDispatcher
+ lateinit var ioDispatcher: CoroutineDispatcher
+
+ @Inject
+ lateinit var logCollect: LogCollect
+
override fun onCreate() {
super.onCreate()
instance = this
@@ -34,6 +57,22 @@ class NymVpn : Application() {
} else {
Timber.plant(ReleaseTree())
}
+ applicationScope.launch(ioDispatcher) {
+ logCollect.start(onLogMessage = { reportLibExceptions(it) })
+ }
+ }
+
+ private fun reportLibExceptions(message: LogMessage) {
+ when (message.level) {
+ LogLevel.ERROR -> {
+ if (message.tag.contains(Constants.NYM_VPN_LIB_TAG)) {
+ Sentry.captureException(
+ NymLibException("${message.time} - ${message.tag} ${message.message}"),
+ )
+ }
+ }
+ else -> Unit
+ }
}
companion object {
diff --git a/app/src/main/java/net/nymtech/nymvpn/data/datastore/DataStoreManager.kt b/app/src/main/java/net/nymtech/nymvpn/data/datastore/DataStoreManager.kt
index c77c338..9c5337c 100644
--- a/app/src/main/java/net/nymtech/nymvpn/data/datastore/DataStoreManager.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/data/datastore/DataStoreManager.kt
@@ -4,14 +4,19 @@ import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.IOException
-class DataStoreManager(private val context: Context) {
+class DataStoreManager(
+ private val context: Context,
+ private val ioDispatcher: CoroutineDispatcher,
+) {
// preferences
private val preferencesKey = "preferences"
@@ -21,29 +26,35 @@ class DataStoreManager(private val context: Context) {
)
suspend fun saveToDataStore(key: Preferences.Key, value: T) {
- try {
- context.dataStore.edit { it[key] = value }
- } catch (e: IOException) {
- Timber.e(e)
+ withContext(ioDispatcher) {
+ try {
+ context.dataStore.edit { it[key] = value }
+ } catch (e: IOException) {
+ Timber.e(e)
+ }
}
}
suspend fun clear(key: Preferences.Key) {
- try {
- context.dataStore.edit { it.remove(key) }
- } catch (e: IOException) {
- Timber.e(e)
+ withContext(ioDispatcher) {
+ try {
+ context.dataStore.edit { it.remove(key) }
+ } catch (e: IOException) {
+ Timber.e(e)
+ }
}
}
fun getFromStoreFlow(key: Preferences.Key) = context.dataStore.data.map { it[key] }
suspend fun getFromStore(key: Preferences.Key): T? {
- return try {
- context.dataStore.data.map { it[key] }.first()
- } catch (e: IOException) {
- Timber.e(e)
- null
+ return withContext(ioDispatcher) {
+ try {
+ context.dataStore.data.map { it[key] }.first()
+ } catch (e: IOException) {
+ Timber.e(e)
+ null
+ }
}
}
diff --git a/app/src/main/java/net/nymtech/nymvpn/data/datastore/DataStoreSettingsRepository.kt b/app/src/main/java/net/nymtech/nymvpn/data/datastore/DataStoreSettingsRepository.kt
index 1b7c7d8..99198d2 100644
--- a/app/src/main/java/net/nymtech/nymvpn/data/datastore/DataStoreSettingsRepository.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/data/datastore/DataStoreSettingsRepository.kt
@@ -44,7 +44,7 @@ class DataStoreSettingsRepository(private val dataStoreManager: DataStoreManager
}
override suspend fun setTheme(theme: Theme) {
- dataStoreManager.saveToDataStore(this.theme, theme.name)
+ dataStoreManager.saveToDataStore(this@DataStoreSettingsRepository.theme, theme.name)
}
override suspend fun getVpnMode(): VpnMode {
@@ -77,7 +77,7 @@ class DataStoreSettingsRepository(private val dataStoreManager: DataStoreManager
}
override suspend fun setLastHopCountry(country: Country) {
- dataStoreManager.saveToDataStore(lastHopCountry, country.toString())
+ return dataStoreManager.saveToDataStore(lastHopCountry, country.toString())
}
override suspend fun isAutoStartEnabled(): Boolean {
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 e88c6ac..c473d35 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,25 +1,35 @@
package net.nymtech.nymvpn.data.datastore
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
import net.nymtech.nymvpn.data.SecretsRepository
+import net.nymtech.nymvpn.module.IoDispatcher
import timber.log.Timber
-class SecretsPreferencesRepository(private val encryptedPreferences: EncryptedPreferences) : SecretsRepository {
+class SecretsPreferencesRepository(
+ private val encryptedPreferences: EncryptedPreferences,
+ @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+) : SecretsRepository {
companion object {
const val CRED = "cred"
}
override suspend fun getCredential(): String? {
- return try {
- encryptedPreferences.sharedPreferences.getString(CRED, null)
- } catch (e: ClassCastException) {
- Timber.e(e)
- null
+ return withContext(ioDispatcher) {
+ try {
+ encryptedPreferences.sharedPreferences.getString(CRED, null)
+ } catch (e: ClassCastException) {
+ Timber.e(e)
+ null
+ }
}
}
override suspend fun saveCredential(credential: String) {
- encryptedPreferences.sharedPreferences.edit().putString(CRED, credential).apply()
+ withContext(ioDispatcher) {
+ 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/data/domain/Settings.kt b/app/src/main/java/net/nymtech/nymvpn/data/domain/Settings.kt
index 484c200..913cc0f 100644
--- a/app/src/main/java/net/nymtech/nymvpn/data/domain/Settings.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/data/domain/Settings.kt
@@ -5,7 +5,7 @@ import net.nymtech.vpn.model.Country
import net.nymtech.vpn.model.VpnMode
data class Settings(
- val theme: Theme = Theme.default(),
+ val theme: Theme? = null,
val vpnMode: VpnMode = VpnMode.default(),
val autoStartEnabled: Boolean = AUTO_START_DEFAULT,
val errorReportingEnabled: Boolean = REPORTING_DEFAULT,
diff --git a/app/src/main/java/net/nymtech/nymvpn/module/Android.kt b/app/src/main/java/net/nymtech/nymvpn/module/ApiQualifiers.kt
similarity index 65%
rename from app/src/main/java/net/nymtech/nymvpn/module/Android.kt
rename to app/src/main/java/net/nymtech/nymvpn/module/ApiQualifiers.kt
index 54345b6..e425f6d 100644
--- a/app/src/main/java/net/nymtech/nymvpn/module/Android.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/module/ApiQualifiers.kt
@@ -5,3 +5,7 @@ import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Android
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class Native
diff --git a/app/src/main/java/net/nymtech/nymvpn/module/AppModule.kt b/app/src/main/java/net/nymtech/nymvpn/module/AppModule.kt
new file mode 100644
index 0000000..47f5693
--- /dev/null
+++ b/app/src/main/java/net/nymtech/nymvpn/module/AppModule.kt
@@ -0,0 +1,122 @@
+package net.nymtech.nymvpn.module
+
+import android.content.Context
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import net.nymtech.logcathelper.LogCollect
+import net.nymtech.logcathelper.LogcatHelper
+import net.nymtech.nymvpn.NymVpn
+import net.nymtech.nymvpn.data.GatewayRepository
+import net.nymtech.nymvpn.service.country.CountryCacheService
+import net.nymtech.nymvpn.service.country.CountryDataStoreCacheService
+import net.nymtech.nymvpn.service.gateway.GatewayApi
+import net.nymtech.nymvpn.service.gateway.GatewayApiService
+import net.nymtech.nymvpn.service.gateway.GatewayLibService
+import net.nymtech.nymvpn.service.gateway.GatewayService
+import net.nymtech.nymvpn.util.Constants
+import net.nymtech.nymvpn.util.FileUtils
+import net.nymtech.vpn.NymApi
+import net.nymtech.vpn.NymVpnClient
+import net.nymtech.vpn.VpnClient
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+@InstallIn(SingletonComponent::class)
+@Module
+object AppModule {
+
+ @Singleton
+ @ApplicationScope
+ @Provides
+ fun providesApplicationScope(@DefaultDispatcher defaultDispatcher: CoroutineDispatcher): CoroutineScope =
+ CoroutineScope(SupervisorJob() + defaultDispatcher)
+
+ @Singleton
+ @Provides
+ fun provideNymApi(@IoDispatcher dispatcher: CoroutineDispatcher): NymApi {
+ return NymApi(NymVpn.environment, dispatcher)
+ }
+
+ @Singleton
+ @Provides
+ fun provideMoshi(): Moshi {
+ return Moshi.Builder()
+ .add(KotlinJsonAdapterFactory())
+ .build()
+ }
+
+ @Singleton
+ @Provides
+ fun provideGatewayService(retrofit: Retrofit): GatewayApi {
+ return retrofit.create(GatewayApi::class.java)
+ }
+
+ @Singleton
+ @Provides
+ fun provideOkHttp(): OkHttpClient {
+ return OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS)
+ .writeTimeout(20, TimeUnit.SECONDS)
+ .build()
+ }
+
+ @Singleton
+ @Provides
+ fun provideRetrofit(moshi: Moshi, okHttpClient: OkHttpClient): Retrofit {
+ return Retrofit.Builder()
+ .client(okHttpClient)
+ .addConverterFactory(MoshiConverterFactory.create(moshi))
+ .baseUrl(Constants.VPN_API_BASE_URL)
+ .build()
+ }
+
+ @Native
+ @Singleton
+ @Provides
+ fun provideGatewayLibService(nymApi: NymApi): GatewayService {
+ return GatewayLibService(nymApi)
+ }
+
+ @Android
+ @Singleton
+ @Provides
+ fun provideGatewayApiService(gatewayApi: GatewayApi, gatewayLibService: GatewayLibService): GatewayService {
+ return GatewayApiService(gatewayApi, gatewayLibService)
+ }
+
+ @Singleton
+ @Provides
+ fun provideCountryCacheService(@Android gatewayService: GatewayService, gatewayRepository: GatewayRepository): CountryCacheService {
+ return CountryDataStoreCacheService(gatewayRepository, gatewayService)
+ }
+
+ @Singleton
+ @Provides
+ fun provideVpnClient(): VpnClient {
+ return NymVpnClient.init(environment = NymVpn.environment)
+ }
+
+ @Singleton
+ @Provides
+ fun provideLogcatHelper(@ApplicationContext context: Context): LogCollect {
+ return LogcatHelper.init(context = context)
+ }
+
+ @Singleton
+ @Provides
+ fun provideFileUtils(@ApplicationContext context: Context, @IoDispatcher dispatcher: CoroutineDispatcher): FileUtils {
+ return FileUtils(context, dispatcher)
+ }
+}
diff --git a/app/src/main/java/net/nymtech/nymvpn/module/CoroutineQualifiers.kt b/app/src/main/java/net/nymtech/nymvpn/module/CoroutineQualifiers.kt
new file mode 100644
index 0000000..4796670
--- /dev/null
+++ b/app/src/main/java/net/nymtech/nymvpn/module/CoroutineQualifiers.kt
@@ -0,0 +1,27 @@
+package net.nymtech.nymvpn.module
+
+import javax.inject.Qualifier
+
+@Retention(AnnotationRetention.RUNTIME)
+@Qualifier
+annotation class DefaultDispatcher
+
+@Retention(AnnotationRetention.RUNTIME)
+@Qualifier
+annotation class IoDispatcher
+
+@Retention(AnnotationRetention.RUNTIME)
+@Qualifier
+annotation class MainDispatcher
+
+@Retention(AnnotationRetention.BINARY)
+@Qualifier
+annotation class MainImmediateDispatcher
+
+@Retention(AnnotationRetention.RUNTIME)
+@Qualifier
+annotation class ApplicationScope
+
+@Retention(AnnotationRetention.RUNTIME)
+@Qualifier
+annotation class ServiceScope
diff --git a/app/src/main/java/net/nymtech/nymvpn/module/CoroutinesDispatchersModule.kt b/app/src/main/java/net/nymtech/nymvpn/module/CoroutinesDispatchersModule.kt
new file mode 100644
index 0000000..bdaf0b8
--- /dev/null
+++ b/app/src/main/java/net/nymtech/nymvpn/module/CoroutinesDispatchersModule.kt
@@ -0,0 +1,29 @@
+package net.nymtech.nymvpn.module
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+
+@InstallIn(SingletonComponent::class)
+@Module
+object CoroutinesDispatchersModule {
+
+ @DefaultDispatcher
+ @Provides
+ fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
+
+ @IoDispatcher
+ @Provides
+ fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
+
+ @MainDispatcher
+ @Provides
+ fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
+
+ @MainImmediateDispatcher
+ @Provides
+ fun providesMainImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate
+}
diff --git a/app/src/main/java/net/nymtech/nymvpn/module/DataModule.kt b/app/src/main/java/net/nymtech/nymvpn/module/DataModule.kt
index 0a96b74..2d982a6 100644
--- a/app/src/main/java/net/nymtech/nymvpn/module/DataModule.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/module/DataModule.kt
@@ -6,6 +6,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineDispatcher
import net.nymtech.nymvpn.data.GatewayRepository
import net.nymtech.nymvpn.data.SecretsRepository
import net.nymtech.nymvpn.data.SettingsRepository
@@ -21,8 +22,8 @@ import javax.inject.Singleton
class DataModule {
@Singleton
@Provides
- fun providePreferencesDataStore(@ApplicationContext context: Context): DataStoreManager {
- return DataStoreManager(context)
+ fun providePreferencesDataStore(@ApplicationContext context: Context, @IoDispatcher dispatcher: CoroutineDispatcher): DataStoreManager {
+ return DataStoreManager(context, dispatcher)
}
@Singleton
@@ -45,7 +46,7 @@ class DataModule {
@Singleton
@Provides
- fun provideSecretsRepository(encryptedPreferences: EncryptedPreferences): SecretsRepository {
- return SecretsPreferencesRepository(encryptedPreferences)
+ fun provideSecretsRepository(encryptedPreferences: EncryptedPreferences, @IoDispatcher dispatcher: CoroutineDispatcher): SecretsRepository {
+ return SecretsPreferencesRepository(encryptedPreferences, dispatcher)
}
}
diff --git a/app/src/main/java/net/nymtech/nymvpn/module/ManagerModule.kt b/app/src/main/java/net/nymtech/nymvpn/module/ManagerModule.kt
index 8778c24..0eda16c 100644
--- a/app/src/main/java/net/nymtech/nymvpn/module/ManagerModule.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/module/ManagerModule.kt
@@ -1,8 +1,10 @@
package net.nymtech.nymvpn.module
+import android.content.Context
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import net.nymtech.nymvpn.service.vpn.NymVpnManager
import net.nymtech.nymvpn.service.vpn.VpnManager
@@ -11,6 +13,11 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class ManagerModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindContext(@ApplicationContext context: Context): Context
+
@Binds
@Singleton
abstract fun bindNymVpnManager(nymVpnManager: NymVpnManager): VpnManager
diff --git a/app/src/main/java/net/nymtech/nymvpn/module/Native.kt b/app/src/main/java/net/nymtech/nymvpn/module/Native.kt
deleted file mode 100644
index 72f0ca4..0000000
--- a/app/src/main/java/net/nymtech/nymvpn/module/Native.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package net.nymtech.nymvpn.module
-
-import javax.inject.Qualifier
-
-@Qualifier
-@Retention(AnnotationRetention.BINARY)
-annotation class Native
diff --git a/app/src/main/java/net/nymtech/nymvpn/module/ServiceModule.kt b/app/src/main/java/net/nymtech/nymvpn/module/ServiceModule.kt
index 0ede7a0..17b621c 100644
--- a/app/src/main/java/net/nymtech/nymvpn/module/ServiceModule.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/module/ServiceModule.kt
@@ -1,83 +1,20 @@
package net.nymtech.nymvpn.module
-import com.squareup.moshi.Moshi
-import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import net.nymtech.nymvpn.NymVpn
-import net.nymtech.nymvpn.data.GatewayRepository
-import net.nymtech.nymvpn.service.country.CountryCacheService
-import net.nymtech.nymvpn.service.country.CountryDataStoreCacheService
-import net.nymtech.nymvpn.service.gateway.GatewayApi
-import net.nymtech.nymvpn.service.gateway.GatewayApiService
-import net.nymtech.nymvpn.service.gateway.GatewayLibService
-import net.nymtech.nymvpn.service.gateway.GatewayService
-import net.nymtech.nymvpn.util.Constants
-import net.nymtech.vpn.NymApi
-import net.nymtech.vpn.NymVpnClient
-import net.nymtech.vpn.VpnClient
-import retrofit2.Retrofit
-import retrofit2.converter.moshi.MoshiConverterFactory
-import javax.inject.Singleton
+import dagger.hilt.android.components.ServiceComponent
+import dagger.hilt.android.scopes.ServiceScoped
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
@Module
-@InstallIn(SingletonComponent::class)
+@InstallIn(ServiceComponent::class)
class ServiceModule {
- @Singleton
@Provides
- fun provideNymApi(): NymApi {
- return NymApi(NymVpn.environment)
- }
-
- @Singleton
- @Provides
- fun provideMoshi(): Moshi {
- return Moshi.Builder()
- .add(KotlinJsonAdapterFactory())
- .build()
- }
-
- @Singleton
- @Provides
- fun provideGatewayService(retrofit: Retrofit): GatewayApi {
- return retrofit.create(GatewayApi::class.java)
- }
-
- @Singleton
- @Provides
- fun provideRetrofit(moshi: Moshi): Retrofit {
- return Retrofit.Builder()
- .addConverterFactory(MoshiConverterFactory.create(moshi))
- .baseUrl(Constants.VPN_API_BASE_URL)
- .build()
- }
-
- @Native
- @Singleton
- @Provides
- fun provideGatewayLibService(nymApi: NymApi): GatewayService {
- return GatewayLibService(nymApi)
- }
-
- @Android
- @Singleton
- @Provides
- fun provideGatewayApiService(gatewayApi: GatewayApi, gatewayLibService: GatewayLibService): GatewayService {
- return GatewayApiService(gatewayApi, gatewayLibService)
- }
-
- @Singleton
- @Provides
- fun provideCountryCacheService(@Android gatewayService: GatewayService, gatewayRepository: GatewayRepository): CountryCacheService {
- return CountryDataStoreCacheService(gatewayRepository, gatewayService)
- }
-
- @Singleton
- @Provides
- fun provideVpnClient(): VpnClient {
- return NymVpnClient.init(environment = NymVpn.environment)
- }
+ @ServiceScope
+ @ServiceScoped
+ fun providesApplicationScope(@IoDispatcher ioDispatcher: CoroutineDispatcher): CoroutineScope = CoroutineScope(SupervisorJob() + ioDispatcher)
}
diff --git a/app/src/main/java/net/nymtech/nymvpn/receiver/BootReceiver.kt b/app/src/main/java/net/nymtech/nymvpn/receiver/BootReceiver.kt
index 128f280..562bff2 100644
--- a/app/src/main/java/net/nymtech/nymvpn/receiver/BootReceiver.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/receiver/BootReceiver.kt
@@ -22,11 +22,9 @@ class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) = goAsync {
if (Intent.ACTION_BOOT_COMPLETED != intent?.action) return@goAsync
if (settingsRepository.isAutoStartEnabled()) {
- context?.let { context ->
- vpnManager.startVpn(context, true).onFailure {
- // TODO handle failures
- Timber.w(it)
- }
+ vpnManager.startVpn(true).onFailure {
+ // TODO handle failures
+ Timber.w(it)
}
}
}
diff --git a/app/src/main/java/net/nymtech/nymvpn/service/AlwaysOnVpnService.kt b/app/src/main/java/net/nymtech/nymvpn/service/AlwaysOnVpnService.kt
index 250d8d2..8b49f73 100644
--- a/app/src/main/java/net/nymtech/nymvpn/service/AlwaysOnVpnService.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/service/AlwaysOnVpnService.kt
@@ -3,10 +3,10 @@ package net.nymtech.nymvpn.service
import android.content.Intent
import android.os.IBinder
import androidx.lifecycle.LifecycleService
-import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
-import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import net.nymtech.nymvpn.module.ApplicationScope
import net.nymtech.nymvpn.service.vpn.VpnManager
import timber.log.Timber
import javax.inject.Inject
@@ -17,6 +17,10 @@ class AlwaysOnVpnService : LifecycleService() {
@Inject
lateinit var vpnManager: VpnManager
+ @Inject
+ @ApplicationScope
+ lateinit var applicationScope: CoroutineScope
+
override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
// We don't provide binding, so return null
@@ -26,8 +30,8 @@ class AlwaysOnVpnService : LifecycleService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null || intent.component == null || intent.component!!.packageName != packageName) {
Timber.i("Always-on VPN requested start")
- lifecycleScope.launch(Dispatchers.IO) {
- vpnManager.startVpn(this@AlwaysOnVpnService, true).onFailure {
+ applicationScope.launch {
+ vpnManager.startVpn(true).onFailure {
// TODO handle failures
Timber.w(it)
}
diff --git a/app/src/main/java/net/nymtech/nymvpn/service/tile/VpnQuickTile.kt b/app/src/main/java/net/nymtech/nymvpn/service/tile/VpnQuickTile.kt
index 44dfcb6..b3da32a 100644
--- a/app/src/main/java/net/nymtech/nymvpn/service/tile/VpnQuickTile.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/service/tile/VpnQuickTile.kt
@@ -5,12 +5,13 @@ import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import net.nymtech.nymvpn.R
import net.nymtech.nymvpn.data.SettingsRepository
+import net.nymtech.nymvpn.module.ApplicationScope
+import net.nymtech.nymvpn.module.ServiceScope
import net.nymtech.nymvpn.service.vpn.VpnManager
import net.nymtech.vpn.VpnClient
import net.nymtech.vpn.model.VpnMode
@@ -31,60 +32,46 @@ class VpnQuickTile : TileService() {
@Inject
lateinit var vpnClient: Provider
- private val scope = CoroutineScope(Dispatchers.IO)
+ @Inject
+ @ServiceScope
+ lateinit var serviceScope: CoroutineScope
+
+ @Inject
+ @ApplicationScope
+ lateinit var applicationScope: CoroutineScope
- private var stateJob: Job? = null
+ private var job: Job? = null
override fun onStartListening() {
super.onStartListening()
Timber.d("Quick tile listening called")
setTileText()
- if (stateJob == null || stateJob?.isCancelled == true) {
- stateJob = scope.launch {
- vpnClient.get().stateFlow.collect {
- when (it.vpnState) {
- VpnState.Up -> {
- Timber.d("VPN State up tile")
- setActive()
- setTileText()
- qsTile.updateTile()
- }
+ val state = vpnClient.get().getState()
+ when (state.vpnState) {
+ VpnState.Up -> {
+ setActive()
+ setTileText()
+ qsTile.updateTile()
+ }
- VpnState.Down -> {
- Timber.d("VPN State down tile")
- setInactive()
- setTileText()
- qsTile.updateTile()
- }
+ VpnState.Down -> {
+ setInactive()
+ setTileText()
+ qsTile.updateTile()
+ }
- VpnState.Connecting.EstablishingConnection, VpnState.Connecting.InitializingClient -> {
- Timber.d("VPN connecting tile")
- setTileDescription(this@VpnQuickTile.getString(R.string.connecting))
- qsTile.updateTile()
- }
+ VpnState.Connecting.EstablishingConnection, VpnState.Connecting.InitializingClient -> {
+ setTileDescription(this@VpnQuickTile.getString(R.string.connecting))
+ qsTile.updateTile()
+ }
- VpnState.Disconnecting -> {
- setTileDescription(this@VpnQuickTile.getString(R.string.disconnecting))
- qsTile.updateTile()
- }
- }
- }
+ VpnState.Disconnecting -> {
+ setTileDescription(this@VpnQuickTile.getString(R.string.disconnecting))
+ qsTile.updateTile()
}
}
}
- override fun onDestroy() {
- super.onDestroy()
- stateJob?.cancel()
- scope.cancel()
- }
-
- override fun onTileRemoved() {
- super.onTileRemoved()
- stateJob?.cancel()
- scope.cancel()
- }
-
override fun onTileAdded() {
super.onTileAdded()
onStartListening()
@@ -96,17 +83,20 @@ class VpnQuickTile : TileService() {
unlockAndRun {
when (vpnClient.get().getState().vpnState) {
VpnState.Up -> {
- scope.launch {
+ applicationScope.launch {
setTileDescription(this@VpnQuickTile.getString(R.string.disconnecting))
qsTile.updateTile()
vpnClient.get().stop(this@VpnQuickTile, true)
+ job = updateOnState(VpnState.Down)
}
}
VpnState.Down -> {
- scope.launch {
- vpnManager.startVpn(this@VpnQuickTile, true).onFailure {
+ applicationScope.launch {
+ vpnManager.startVpn(true).onFailure {
// TODO handle failure
Timber.w(it)
+ }.onSuccess {
+ job = updateOnState(VpnState.Up)
}
}
}
@@ -115,7 +105,16 @@ class VpnQuickTile : TileService() {
}
}
- private fun setTileText() = scope.launch {
+ private suspend fun updateOnState(vpnState: VpnState) = serviceScope.launch {
+ vpnClient.get().stateFlow.collect {
+ if (it.vpnState == vpnState) {
+ onStartListening()
+ job?.cancel()
+ }
+ }
+ }
+
+ private fun setTileText() = serviceScope.launch {
val firstHopCountry = settingsRepository.getFirstHopCountry()
val lastHopCountry = settingsRepository.getLastHopCountry()
val mode = settingsRepository.getVpnMode()
diff --git a/app/src/main/java/net/nymtech/nymvpn/service/vpn/NymVpnManager.kt b/app/src/main/java/net/nymtech/nymvpn/service/vpn/NymVpnManager.kt
index 164e7f0..e45549a 100644
--- a/app/src/main/java/net/nymtech/nymvpn/service/vpn/NymVpnManager.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/service/vpn/NymVpnManager.kt
@@ -13,13 +13,14 @@ class NymVpnManager @Inject constructor(
private val settingsRepository: SettingsRepository,
private val secretsRepository: Provider,
private val vpnClient: Provider,
+ private val context: Context,
) : VpnManager {
- override fun stopVpn(context: Context, foreground: Boolean) {
- vpnClient.get().stop(NymVpn.instance, foreground)
+ override suspend fun stopVpn(foreground: Boolean) {
+ vpnClient.get().stop(context, foreground)
NymVpn.requestTileServiceStateUpdate()
}
- override suspend fun startVpn(context: Context, foreground: Boolean): Result {
+ override suspend fun startVpn(foreground: Boolean): Result {
val entryCountry = settingsRepository.getFirstHopCountry()
val exitCountry = settingsRepository.getLastHopCountry()
val credential = secretsRepository.get().getCredential()
@@ -27,16 +28,12 @@ class NymVpnManager @Inject constructor(
return if (credential != null) {
val entry = entryCountry.toEntryPoint()
val exit = exitCountry.toExitPoint()
- try {
- vpnClient.get().apply {
- this.mode = mode
- this.exitPoint = exit
- this.entryPoint = entry
- }.start(context, credential, true)
+ return vpnClient.get().apply {
+ this.mode = mode
+ this.exitPoint = exit
+ this.entryPoint = entry
+ }.start(context, credential, true).also {
NymVpn.requestTileServiceStateUpdate()
- Result.success(Unit)
- } catch (e: InvalidCredentialException) {
- Result.failure(e)
}
} else {
Result.failure(InvalidCredentialException("No credential found"))
diff --git a/app/src/main/java/net/nymtech/nymvpn/service/vpn/VpnManager.kt b/app/src/main/java/net/nymtech/nymvpn/service/vpn/VpnManager.kt
index 2777fb7..e029ff6 100644
--- a/app/src/main/java/net/nymtech/nymvpn/service/vpn/VpnManager.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/service/vpn/VpnManager.kt
@@ -1,9 +1,7 @@
package net.nymtech.nymvpn.service.vpn
-import android.content.Context
-
interface VpnManager {
- fun stopVpn(context: Context, foreground: Boolean)
- suspend fun startVpn(context: Context, foreground: Boolean): Result
+ suspend fun stopVpn(foreground: Boolean)
+ suspend fun startVpn(foreground: Boolean): Result
}
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 387b2f5..c3d3d11 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/AppUiState.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/AppUiState.kt
@@ -5,7 +5,6 @@ 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(),
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 4eef268..f48d5ec 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/AppViewModel.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/AppViewModel.kt
@@ -9,13 +9,10 @@ import android.os.Build
import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM
import android.widget.Toast
import androidx.annotation.RequiresApi
-import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
-import io.sentry.Sentry
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
@@ -23,19 +20,15 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-import net.nymtech.logcathelper.LogcatHelper
-import net.nymtech.logcathelper.model.LogLevel
-import net.nymtech.logcathelper.model.LogMessage
import net.nymtech.nymvpn.R
import net.nymtech.nymvpn.data.GatewayRepository
import net.nymtech.nymvpn.data.SecretsRepository
import net.nymtech.nymvpn.data.SettingsRepository
+import net.nymtech.nymvpn.module.IoDispatcher
import net.nymtech.nymvpn.module.Native
import net.nymtech.nymvpn.service.gateway.GatewayService
import net.nymtech.nymvpn.util.Constants
-import net.nymtech.nymvpn.util.FileUtils
import net.nymtech.nymvpn.util.NymVpnExceptions
-import net.nymtech.nymvpn.util.log.NymLibException
import net.nymtech.vpn.VpnClient
import net.nymtech.vpn.model.Country
import timber.log.Timber
@@ -52,15 +45,13 @@ constructor(
private val gatewayRepository: GatewayRepository,
@Native private val gatewayService: GatewayService,
private val vpnClient: Provider,
+ @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {
private val _uiState = MutableStateFlow(AppUiState())
- val logs = mutableStateListOf()
- private val logsBuffer = mutableListOf()
-
init {
- viewModelScope.launch(Dispatchers.IO) {
+ viewModelScope.launch(ioDispatcher) {
secretsRepository.get().credentialFlow.collect { cred ->
cred?.let {
getCredentialExpiry(it).onSuccess { expiry ->
@@ -82,7 +73,6 @@ constructor(
secretsRepository.get().credentialFlow,
) { state, settings, vpnState, cred ->
AppUiState(
- false,
state.snackbarMessage,
state.snackbarMessageConsumed,
vpnState,
@@ -96,34 +86,6 @@ constructor(
AppUiState(),
)
- fun readLogCatOutput() = viewModelScope.launch(viewModelScope.coroutineContext + Dispatchers.IO) {
- launch {
- LogcatHelper.logs {
- logsBuffer.add(it)
- when (it.level) {
- LogLevel.ERROR -> {
- if (it.tag.contains(Constants.NYM_VPN_LIB_TAG)) {
- Sentry.captureException(
- NymLibException("${it.time} - ${it.tag} ${it.message}"),
- )
- }
- }
- else -> Unit
- }
- }
- }
- launch {
- do {
- logs.addAll(logsBuffer)
- logsBuffer.clear()
- if (logs.size > Constants.LOG_BUFFER_SIZE) {
- logs.removeRange(0, (logs.size - Constants.LOG_BUFFER_SIZE).toInt())
- }
- delay(Constants.LOG_BUFFER_DELAY)
- } while (true)
- }
- }
-
private fun setCredentialExpiry(instant: Instant) {
_uiState.update {
it.copy(
@@ -140,14 +102,8 @@ constructor(
}
}
- fun clearLogs() {
- logs.clear()
- logsBuffer.clear()
- LogcatHelper.clear()
- }
-
suspend fun onValidCredentialCheck(): Result {
- return withContext(viewModelScope.coroutineContext + Dispatchers.IO) {
+ return withContext(viewModelScope.coroutineContext) {
val credential = secretsRepository.get().getCredential()
if (credential != null) {
getCredentialExpiry(credential)
@@ -163,18 +119,11 @@ constructor(
}
}
- fun saveLogsToFile(context: Context) {
- val fileName = "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.txt"
- val content = logs.joinToString(separator = "\n")
- FileUtils.saveFileToDownloads(context, content, fileName)
- showSnackbarMessage(context.getString(R.string.logs_saved))
- }
-
fun setAnalyticsShown() = viewModelScope.launch {
settingsRepository.setAnalyticsShown(true)
}
- fun onEntryLocationSelected(selected: Boolean) = viewModelScope.launch(Dispatchers.IO) {
+ fun onEntryLocationSelected(selected: Boolean) = viewModelScope.launch {
settingsRepository.setFirstHopSelection(selected)
settingsRepository.setFirstHopCountry(Country(isDefault = true))
// launch {
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 b9c52b3..ddc774e 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/MainActivity.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/MainActivity.kt
@@ -30,8 +30,9 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import dagger.hilt.android.AndroidEntryPoint
-import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
+import net.nymtech.nymvpn.module.MainImmediateDispatcher
import net.nymtech.nymvpn.ui.common.labels.CustomSnackBar
import net.nymtech.nymvpn.ui.common.navigation.NavBar
import net.nymtech.nymvpn.ui.screens.analytics.AnalyticsScreen
@@ -48,15 +49,22 @@ import net.nymtech.nymvpn.ui.screens.settings.legal.licenses.LicensesScreen
import net.nymtech.nymvpn.ui.screens.settings.logs.LogsScreen
import net.nymtech.nymvpn.ui.screens.settings.support.SupportScreen
import net.nymtech.nymvpn.ui.theme.NymVPNTheme
+import net.nymtech.nymvpn.ui.theme.Theme
import net.nymtech.nymvpn.util.StringValue
+import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
+ @Inject
+ @MainImmediateDispatcher
+ lateinit var mainImmediateDispatcher: CoroutineDispatcher
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val isAnalyticsShown = intent.extras?.getBoolean(SplashActivity.IS_ANALYTICS_SHOWN_INTENT_KEY)
+ val theme = intent.extras?.getString(SplashActivity.THEME)
setContent {
val appViewModel = hiltViewModel()
@@ -66,12 +74,8 @@ class MainActivity : ComponentActivity() {
var navHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
- LaunchedEffect(Unit) {
- appViewModel.readLogCatOutput()
- }
-
fun showSnackBarMessage(message: StringValue) {
- lifecycleScope.launch(Dispatchers.Main) {
+ lifecycleScope.launch(mainImmediateDispatcher) {
val result =
snackbarHostState.showSnackbar(
message = message.asString(this@MainActivity),
@@ -94,7 +98,11 @@ class MainActivity : ComponentActivity() {
}
}
- NymVPNTheme(theme = uiState.settings.theme) {
+ fun getTheme(): Theme {
+ return uiState.settings.theme ?: theme?.let { Theme.valueOf(it) } ?: Theme.default()
+ }
+
+ NymVPNTheme(theme = getTheme()) {
Scaffold(
Modifier.semantics {
// Enables testTag -> UiAutomator resource id
@@ -103,7 +111,6 @@ class MainActivity : ComponentActivity() {
},
topBar = {
NavBar(
- appViewModel,
uiState,
navController,
Modifier
@@ -151,7 +158,7 @@ class MainActivity : ComponentActivity() {
)
}
composable(NavItem.Settings.Display.route) { DisplayScreen() }
- composable(NavItem.Settings.Logs.route) { LogsScreen(appViewModel) }
+ composable(NavItem.Settings.Logs.route) { LogsScreen(appViewModel = appViewModel) }
composable(NavItem.Settings.Support.route) { SupportScreen(appViewModel) }
composable(NavItem.Settings.Feedback.route) { FeedbackScreen(appViewModel) }
composable(NavItem.Settings.Legal.route) {
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/Navigation.kt b/app/src/main/java/net/nymtech/nymvpn/ui/Navigation.kt
index c2dc608..5522ce1 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/Navigation.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/Navigation.kt
@@ -2,7 +2,6 @@ package net.nymtech.nymvpn.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.outlined.DeleteForever
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.ui.graphics.vector.ImageVector
import net.nymtech.nymvpn.R
@@ -70,7 +69,6 @@ sealed class NavItem(
"${Screen.SETTINGS.name}/${Screen.LOGS.name}",
StringValue.StringResource(R.string.logs),
backIcon,
- trailing = clearLogsIcon,
)
data object Feedback : NavItem(
@@ -121,7 +119,6 @@ sealed class NavItem(
companion object {
val settingsIcon = Icons.Outlined.Settings
val backIcon = Icons.AutoMirrored.Filled.ArrowBack
- val clearLogsIcon = Icons.Outlined.DeleteForever
fun from(route: String?): NavItem {
return when (route) {
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 a359ff2..59c0f3e 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/ShortcutActivity.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/ShortcutActivity.kt
@@ -3,11 +3,13 @@ package net.nymtech.nymvpn.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import net.nymtech.nymvpn.data.SettingsRepository
+import net.nymtech.nymvpn.module.ApplicationScope
+import net.nymtech.nymvpn.module.IoDispatcher
import net.nymtech.nymvpn.service.vpn.VpnManager
import net.nymtech.vpn.util.Action
import timber.log.Timber
@@ -22,20 +24,29 @@ class ShortcutActivity : ComponentActivity() {
@Inject
lateinit var settingsRepository: SettingsRepository
- private val scope = CoroutineScope(Dispatchers.Main)
+ @Inject
+ @ApplicationScope
+ lateinit var applicationScope: CoroutineScope
+
+ @Inject
+ @IoDispatcher
+ lateinit var ioDispatcher: CoroutineDispatcher
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- scope.launch(Dispatchers.IO) {
- if (settingsRepository.isApplicationShortcutsEnabled()) {
+ applicationScope.launch {
+ val enabled = withContext(ioDispatcher) {
+ settingsRepository.isApplicationShortcutsEnabled()
+ }
+ if (enabled) {
when (intent.action) {
Action.START.name -> {
- vpnManager.startVpn(this@ShortcutActivity, true).onFailure {
+ vpnManager.startVpn(true).onFailure {
Timber.w(it)
}
}
Action.STOP.name -> {
- vpnManager.stopVpn(this@ShortcutActivity, true)
+ vpnManager.stopVpn(true)
}
}
} else {
@@ -44,9 +55,4 @@ 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 48e50a8..939a3e8 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/SplashActivity.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/SplashActivity.kt
@@ -11,14 +11,13 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import dagger.hilt.android.AndroidEntryPoint
import io.sentry.android.core.SentryAndroid
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withTimeout
+import net.nymtech.logcathelper.LogCollect
import net.nymtech.nymvpn.BuildConfig
import net.nymtech.nymvpn.NymVpn
import net.nymtech.nymvpn.data.SettingsRepository
+import net.nymtech.nymvpn.module.ApplicationScope
import net.nymtech.nymvpn.service.country.CountryCacheService
import net.nymtech.nymvpn.util.Constants
import timber.log.Timber
@@ -34,38 +33,48 @@ class SplashActivity : ComponentActivity() {
@Inject
lateinit var settingsRepository: SettingsRepository
+ @Inject
+ lateinit var logCollect: LogCollect
+
+ @Inject
+ @ApplicationScope
+ lateinit var applicationScope: CoroutineScope
+
override fun onCreate(savedInstanceState: Bundle?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition { true }
}
super.onCreate(savedInstanceState)
- lifecycleScope.launch(Dispatchers.IO) {
+
+ applicationScope.launch {
+ launch {
+ Timber.d("Updating exit country cache")
+ countryCacheService.updateExitCountriesCache().onSuccess {
+ Timber.d("Exit countries updated")
+ }.onFailure { Timber.w("Failed to get exit countries: ${it.message}") }
+ }
+ launch {
+ Timber.d("Updating entry country cache")
+ countryCacheService.updateEntryCountriesCache().onSuccess {
+ Timber.d("Entry countries updated")
+ }.onFailure { Timber.w("Failed to get entry countries: ${it.message}") }
+ }
+ }
+
+ lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
// init data
settingsRepository.init()
configureSentry()
- withTimeout(3000) {
- listOf(
- async {
- Timber.d("Updating exit country cache")
- countryCacheService.updateExitCountriesCache()
- Timber.d("Exit countries updated")
- },
- async {
- Timber.d("Updating entry country cache")
- countryCacheService.updateEntryCountriesCache()
- Timber.d("Entry countries updated")
- },
- ).awaitAll()
- }
-
val isAnalyticsShown = settingsRepository.isAnalyticsShown()
+ val theme = settingsRepository.getTheme()
val intent = Intent(this@SplashActivity, MainActivity::class.java).apply {
putExtra(IS_ANALYTICS_SHOWN_INTENT_KEY, isAnalyticsShown)
+ putExtra(THEME, theme.name)
}
startActivity(intent)
finish()
@@ -89,7 +98,9 @@ class SplashActivity : ComponentActivity() {
}
}
}
+
companion object {
const val IS_ANALYTICS_SHOWN_INTENT_KEY = "is_analytics_shown"
+ const val THEME = "theme"
}
}
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/common/navigation/NavBar.kt b/app/src/main/java/net/nymtech/nymvpn/ui/common/navigation/NavBar.kt
index 2e77a91..a9343fb 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/common/navigation/NavBar.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/common/navigation/NavBar.kt
@@ -24,14 +24,13 @@ import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
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.theme.Theme
import net.nymtech.nymvpn.ui.theme.iconSize
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun NavBar(appViewModel: AppViewModel, appUiState: AppUiState, navController: NavController, modifier: Modifier = Modifier) {
+fun NavBar(appUiState: AppUiState, navController: NavController, modifier: Modifier = Modifier) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val navItem = NavItem.from(navBackStackEntry?.destination?.route)
val context = LocalContext.current
@@ -52,6 +51,7 @@ fun NavBar(appViewModel: AppViewModel, appUiState: AppUiState, navController: Na
Theme.AUTOMATIC -> isSystemInDarkTheme()
Theme.DARK_MODE -> true
Theme.LIGHT_MODE -> false
+ else -> true
}
if (darkTheme) {
Icon(ImageVector.vectorResource(R.drawable.app_label_dark), "app_label", tint = Color.Unspecified)
@@ -71,7 +71,6 @@ fun NavBar(appViewModel: AppViewModel, appUiState: AppUiState, navController: Na
onClick = {
when (it) {
NavItem.settingsIcon -> navController.navigate(NavItem.Settings.route)
- NavItem.clearLogsIcon -> appViewModel.clearLogs()
}
},
) {
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/hop/HopViewModel.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/hop/HopViewModel.kt
index 1bf6258..b5d64c7 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/hop/HopViewModel.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/hop/HopViewModel.kt
@@ -3,7 +3,6 @@ package net.nymtech.nymvpn.ui.screens.hop
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
@@ -74,14 +73,14 @@ constructor(
setSelectedCountry()
}
- fun updateCountryCache(hopType: HopType) = viewModelScope.launch(Dispatchers.IO) {
+ fun updateCountryCache(hopType: HopType) = viewModelScope.launch {
when (hopType) {
HopType.FIRST -> countryCacheService.updateEntryCountriesCache()
HopType.LAST -> countryCacheService.updateExitCountriesCache()
}
}
- private fun setSelectedCountry() = viewModelScope.launch(Dispatchers.IO) {
+ private fun setSelectedCountry() = viewModelScope.launch {
val selectedCountry =
when (_uiState.value.hopType) {
HopType.FIRST -> settingsRepository.getFirstHopCountry()
@@ -94,7 +93,7 @@ constructor(
}
}
- fun onSelected(country: Country) = viewModelScope.launch(Dispatchers.IO) {
+ fun onSelected(country: Country) = viewModelScope.launch {
when (_uiState.value.hopType) {
HopType.FIRST -> settingsRepository.setFirstHopCountry(country)
HopType.LAST -> settingsRepository.setLastHopCountry(country)
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/main/MainViewModel.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/main/MainViewModel.kt
index b1d999b..d77bebd 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/main/MainViewModel.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/main/MainViewModel.kt
@@ -3,13 +3,11 @@ package net.nymtech.nymvpn.ui.screens.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-import net.nymtech.nymvpn.NymVpn
import net.nymtech.nymvpn.R
import net.nymtech.nymvpn.data.SettingsRepository
import net.nymtech.nymvpn.service.vpn.VpnManager
@@ -80,11 +78,11 @@ constructor(
settingsRepository.setVpnMode(VpnMode.FIVE_HOP_MIXNET)
}
- suspend fun onConnect(): Result = withContext(viewModelScope.coroutineContext + Dispatchers.IO) {
- vpnManager.startVpn(NymVpn.instance, false)
+ suspend fun onConnect(): Result = withContext(viewModelScope.coroutineContext) {
+ vpnManager.startVpn(false)
}
fun onDisconnect() = viewModelScope.launch {
- vpnManager.stopVpn(NymVpn.instance, false)
+ vpnManager.stopVpn(false)
}
}
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 e9a3482..eddd3de 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
@@ -38,7 +38,7 @@ constructor(
}
fun onKillSwitchSelected(context: Context) {
- val intent = Intent("android.net.vpn.SETTINGS").apply {
+ val intent = Intent(Constants.VPN_SETTINGS_PACKAGE).apply {
setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
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 d93ee90..7cb7278 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
@@ -3,7 +3,7 @@ package net.nymtech.nymvpn.ui.screens.settings.credential
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.nymtech.nymvpn.data.SecretsRepository
import net.nymtech.vpn.VpnClient
@@ -18,16 +18,17 @@ constructor(
private val secretsRepository: Provider,
private val vpnClient: Provider,
) : ViewModel() {
+
suspend fun onImportCredential(credential: String): Result {
val trimmedCred = credential.trim()
- return withContext(viewModelScope.coroutineContext + Dispatchers.IO) {
+ return withContext(viewModelScope.coroutineContext) {
vpnClient.get().validateCredential(trimmedCred).onSuccess {
saveCredential(credential)
}
}
}
- private suspend fun saveCredential(credential: String) {
+ private fun saveCredential(credential: String) = viewModelScope.launch {
secretsRepository.get().saveCredential(credential)
}
}
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/display/DisplayViewModel.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/display/DisplayViewModel.kt
index 7bf710f..bd1c7b3 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/display/DisplayViewModel.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/display/DisplayViewModel.kt
@@ -20,7 +20,7 @@ constructor(
) : ViewModel() {
val uiState =
settingsRepository.settingsFlow.map {
- DisplayUiState(false, it.theme)
+ DisplayUiState(false, it.theme ?: Theme.default())
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/legal/licenses/LicenseParser.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/legal/licenses/LicenseParser.kt
index 011f9da..65f42f9 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/legal/licenses/LicenseParser.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/legal/licenses/LicenseParser.kt
@@ -1,11 +1,19 @@
package net.nymtech.nymvpn.ui.screens.settings.legal.licenses
+import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
-import okio.BufferedSource
+import timber.log.Timber
object LicenseParser {
- fun decode(source: BufferedSource): List {
- return Json.decodeFromString>(source.readString(Charsets.UTF_8))
- .distinctBy { it.name }
+ fun decode(licenseJson: String): List {
+ try {
+ return Json.decodeFromString>(licenseJson)
+ .distinctBy { it.name }
+ } catch (e: SerializationException) {
+ Timber.e(e)
+ } catch (e: IllegalArgumentException) {
+ Timber.e(e)
+ }
+ return emptyList()
}
}
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 f8b5c4c..d7dfbf3 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
@@ -40,7 +40,7 @@ fun LicensesScreen(appViewModel: AppViewModel, viewModel: LicensesViewModel = hi
}
LaunchedEffect(Unit) {
- viewModel.loadLicensesFromAssets(context)
+ viewModel.loadLicensesFromAssets()
}
LazyColumn(
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/legal/licenses/LicensesViewModel.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/legal/licenses/LicensesViewModel.kt
index 6934204..2d8bf0a 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/legal/licenses/LicensesViewModel.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/legal/licenses/LicensesViewModel.kt
@@ -1,6 +1,5 @@
package net.nymtech.nymvpn.ui.screens.settings.legal.licenses
-import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -8,22 +7,23 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import okio.buffer
-import okio.source
+import net.nymtech.nymvpn.util.FileUtils
import javax.inject.Inject
@HiltViewModel
class LicensesViewModel
@Inject
-constructor() : ViewModel() {
+constructor(
+ private val fileUtils: FileUtils,
+) : ViewModel() {
private val _licences = MutableStateFlow>(emptyList())
val licenses = _licences.asStateFlow()
+ private val licensesFileName = "artifacts.json"
- fun loadLicensesFromAssets(context: Context) = viewModelScope.launch {
- val source = context.assets.open("artifacts.json").source().buffer()
+ fun loadLicensesFromAssets() = viewModelScope.launch {
+ val text = fileUtils.readTextFromFileName(licensesFileName)
_licences.update {
- LicenseParser.decode(source)
+ LicenseParser.decode(text)
}
- source.close()
}
}
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/logs/LogsScreen.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/logs/LogsScreen.kt
index 3ec09ff..f14758a 100644
--- a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/logs/LogsScreen.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/logs/LogsScreen.kt
@@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -20,6 +20,7 @@ import androidx.compose.material3.Scaffold
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.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@@ -31,25 +32,25 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.launch
+import net.nymtech.logcathelper.model.LogMessage
+import net.nymtech.nymvpn.R
import net.nymtech.nymvpn.ui.AppViewModel
import net.nymtech.nymvpn.ui.common.labels.LogTypeLabel
import net.nymtech.nymvpn.util.scaledWidth
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
-fun LogsScreen(appViewModel: AppViewModel) {
- val logs =
- remember {
- appViewModel.logs
- }
-
- val context = LocalContext.current
-
+fun LogsScreen(viewModel: LogsViewModel = hiltViewModel(), appViewModel: AppViewModel) {
val lazyColumnListState = rememberLazyListState()
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+
+ val logs = viewModel.logs
+
LaunchedEffect(logs.size) {
scope.launch {
lazyColumnListState.animateScrollToItem(logs.size)
@@ -60,7 +61,13 @@ fun LogsScreen(appViewModel: AppViewModel) {
floatingActionButton = {
FloatingActionButton(
onClick = {
- appViewModel.saveLogsToFile(context)
+ scope.launch {
+ viewModel.saveLogsToFile().onSuccess {
+ appViewModel.showSnackbarMessage(context.getString(R.string.logs_saved))
+ }.onFailure {
+ appViewModel.showSnackbarMessage(context.getString(R.string.error_logs_not_saved))
+ }
+ }
},
shape = RoundedCornerShape(16.dp),
containerColor = MaterialTheme.colorScheme.primary,
@@ -84,7 +91,7 @@ fun LogsScreen(appViewModel: AppViewModel) {
.padding(top = 5.dp)
.padding(horizontal = 24.dp.scaledWidth()),
) {
- items(logs) {
+ itemsIndexed(logs, key = { index, _ -> index }, contentType = { _: Int, _: LogMessage -> null }) { _, it ->
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.Start),
verticalAlignment = Alignment.Top,
diff --git a/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/logs/LogsViewModel.kt b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/logs/LogsViewModel.kt
new file mode 100644
index 0000000..cc3f4ad
--- /dev/null
+++ b/app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/logs/LogsViewModel.kt
@@ -0,0 +1,54 @@
+package net.nymtech.nymvpn.ui.screens.settings.logs
+
+import androidx.compose.runtime.mutableStateListOf
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import net.nymtech.logcathelper.LogCollect
+import net.nymtech.logcathelper.model.LogMessage
+import net.nymtech.nymvpn.module.IoDispatcher
+import net.nymtech.nymvpn.module.MainDispatcher
+import net.nymtech.nymvpn.util.Constants
+import net.nymtech.nymvpn.util.FileUtils
+import net.nymtech.nymvpn.util.chunked
+import java.time.Duration
+import java.time.Instant
+import javax.inject.Inject
+
+@HiltViewModel
+class LogsViewModel @Inject constructor(
+ private val logCollect: LogCollect,
+ private val fileUtils: FileUtils,
+ @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+ @MainDispatcher private val mainDispatcher: CoroutineDispatcher,
+) : ViewModel() {
+
+ val logs = mutableStateListOf()
+
+ init {
+ viewModelScope.launch(ioDispatcher) {
+ logCollect.bufferedLogs.chunked(500, Duration.ofSeconds(1)).collect {
+ withContext(mainDispatcher) {
+ logs.addAll(it)
+ }
+ if (logs.size > Constants.LOG_BUFFER_SIZE) {
+ withContext(mainDispatcher) {
+ logs.removeRange(0, (logs.size - Constants.LOG_BUFFER_SIZE).toInt())
+ }
+ }
+ }
+ }
+ }
+
+ suspend fun saveLogsToFile(): Result {
+ val file = logCollect.getLogFile().getOrElse {
+ return Result.failure(it)
+ }
+ val fileContent = fileUtils.readBytesFromFile(file)
+ val fileName = "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.txt"
+ return fileUtils.saveByteArrayToDownloads(fileContent, fileName)
+ }
+}
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 728a0c6..a5525fa 100644
--- a/app/src/main/java/net/nymtech/nymvpn/util/Constants.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/util/Constants.kt
@@ -4,7 +4,6 @@ object Constants {
const val VPN_API_BASE_URL = "https://nymvpn.com/api/"
const val SUBSCRIPTION_TIMEOUT = 5_000L
- const val LOG_BUFFER_DELAY = 3_000L
const val LOG_BUFFER_SIZE = 5_000L
const val EMAIL_MIME_TYPE = "message/rfc822"
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 4f04934..38b6972 100644
--- a/app/src/main/java/net/nymtech/nymvpn/util/Extensions.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/util/Extensions.kt
@@ -7,15 +7,27 @@ import androidx.compose.ui.unit.TextUnit
import androidx.navigation.NavController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.ObsoleteCoroutinesApi
+import kotlinx.coroutines.channels.ClosedReceiveChannelException
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.channels.produce
+import kotlinx.coroutines.channels.ticker
+import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.launch
+import kotlinx.coroutines.selects.whileSelect
import net.nymtech.nymvpn.NymVpn
+import timber.log.Timber
import java.time.Duration
import java.time.Instant
+import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.coroutineContext
fun Dp.scaledHeight(): Dp {
@@ -62,3 +74,63 @@ fun NavController.navigateNoBack(route: String) {
fun Instant.durationFromNow(): Duration {
return Duration.between(Instant.now(), this)
}
+
+/**
+ * Chunks based on a time or size threshold.
+ *
+ * Borrowed from this [Stack Overflow question](https://stackoverflow.com/questions/51022533/kotlin-chunk-sequence-based-on-size-and-time).
+ */
+@OptIn(ObsoleteCoroutinesApi::class)
+fun ReceiveChannel.chunked(scope: CoroutineScope, size: Int, time: Duration) = scope.produce> {
+ while (true) { // this loop goes over each chunk
+ val chunk = ConcurrentLinkedQueue() // current chunk
+ val ticker = ticker(time.toMillis()) // time-limit for this chunk
+ try {
+ whileSelect {
+ ticker.onReceive {
+ false // done with chunk when timer ticks, takes priority over received elements
+ }
+ this@chunked.onReceive {
+ chunk += it
+ chunk.size < size // continue whileSelect if chunk is not full
+ }
+ }
+ } catch (e: ClosedReceiveChannelException) {
+ Timber.e(e)
+ return@produce
+ } finally {
+ ticker.cancel()
+ if (chunk.isNotEmpty()) {
+ send(chunk.toList())
+ }
+ }
+ }
+}
+
+@OptIn(DelicateCoroutinesApi::class)
+fun Flow.chunked(size: Int, time: Duration) = channelFlow {
+ coroutineScope {
+ val channel = asChannel(this@chunked).chunked(this, size, time)
+ try {
+ while (!channel.isClosedForReceive) {
+ send(channel.receive())
+ }
+ } catch (e: ClosedReceiveChannelException) {
+ // Channel was closed by the flow completing, nothing to do
+ Timber.w(e)
+ } catch (e: CancellationException) {
+ channel.cancel(e)
+ throw e
+ } catch (e: Exception) {
+ channel.cancel(CancellationException("Closing channel due to flow exception", e))
+ throw e
+ }
+ }
+}
+
+@ExperimentalCoroutinesApi
+fun CoroutineScope.asChannel(flow: Flow): ReceiveChannel = produce {
+ flow.collect { value ->
+ channel.send(value)
+ }
+}
diff --git a/app/src/main/java/net/nymtech/nymvpn/util/FileUtils.kt b/app/src/main/java/net/nymtech/nymvpn/util/FileUtils.kt
index ea81910..6955da6 100644
--- a/app/src/main/java/net/nymtech/nymvpn/util/FileUtils.kt
+++ b/app/src/main/java/net/nymtech/nymvpn/util/FileUtils.kt
@@ -5,33 +5,65 @@ import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
import java.io.File
+import java.io.FileInputStream
import java.io.FileOutputStream
-object FileUtils {
- fun saveFileToDownloads(context: Context, content: String, fileName: String) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- val contentValues =
- ContentValues().apply {
- put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
- put(MediaStore.MediaColumns.MIME_TYPE, Constants.TEXT_MIME_TYPE)
- put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
- }
- val resolver = context.contentResolver
- val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
- if (uri != null) {
- resolver.openOutputStream(uri).use { output ->
- output?.write(content.toByteArray())
+class FileUtils(
+ private val context: Context,
+ private val ioDispatcher: CoroutineDispatcher,
+) {
+
+ suspend fun readBytesFromFile(file: File): ByteArray {
+ return withContext(ioDispatcher) {
+ FileInputStream(file).use {
+ it.readBytes()
+ }
+ }
+ }
+
+ suspend fun readTextFromFileName(fileName: String): String {
+ return withContext(ioDispatcher) {
+ context.assets.open(fileName).use { stream ->
+ stream.bufferedReader(Charsets.UTF_8).use {
+ it.readText()
}
}
- } else {
- val target =
- File(
- Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
- fileName,
- )
- FileOutputStream(target).use { output ->
- output.write(content.toByteArray())
+ }
+ }
+
+ suspend fun saveByteArrayToDownloads(content: ByteArray, fileName: String): Result {
+ return withContext(ioDispatcher) {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val contentValues =
+ ContentValues().apply {
+ put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
+ put(MediaStore.MediaColumns.MIME_TYPE, Constants.TEXT_MIME_TYPE)
+ put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
+ }
+ val resolver = context.contentResolver
+ val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
+ if (uri != null) {
+ resolver.openOutputStream(uri).use { output ->
+ output?.write(content)
+ }
+ }
+ } else {
+ val target =
+ File(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
+ fileName,
+ )
+ FileOutputStream(target).use { output ->
+ output.write(content)
+ }
+ }
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
}
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7b73dca..9d5bb61 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -131,4 +131,5 @@
days
day
Scan NymVPN Credential QR
+ Failed to save logs
diff --git a/buildSrc/src/main/kotlin/Constants.kt b/buildSrc/src/main/kotlin/Constants.kt
index df03c20..d1fc02e 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.3"
- const val VERSION_CODE = 10300
+ const val VERSION_NAME = "v1.0.4"
+ const val VERSION_CODE = 10400
const val TARGET_SDK = 34
const val COMPILE_SDK = 34
const val MIN_SDK = 24
diff --git a/config/detekt.yml b/config/detekt.yml
index e993dd2..07f8179 100644
--- a/config/detekt.yml
+++ b/config/detekt.yml
@@ -36,6 +36,8 @@ comments:
complexity:
active: true
+ NestedBlockDepth:
+ threshold: 6
LongParameterList:
active: false
TooManyFunctions:
diff --git a/fastlane/metadata/android/en-US/changelogs/10400.txt b/fastlane/metadata/android/en-US/changelogs/10400.txt
new file mode 100644
index 0000000..4027d88
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/10400.txt
@@ -0,0 +1,4 @@
+What's new:
+- Improved logging to file
+- Bug fixes
+- Other efficiencies and improvements
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 36f41e9..427449f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -106,7 +106,6 @@ androidx-window-core-android = { group = "androidx.window", name = "window-core-
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
-
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hiltAndroid" }
diff --git a/logcat_helper/build.gradle.kts b/logcat_helper/build.gradle.kts
index 35ccbbe..cf12333 100644
--- a/logcat_helper/build.gradle.kts
+++ b/logcat_helper/build.gradle.kts
@@ -58,5 +58,7 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
+ implementation(libs.timber)
+
detektPlugins(libs.detekt.rules.compose)
}
diff --git a/logcat_helper/src/main/java/net/nymtech/logcathelper/LogCollect.kt b/logcat_helper/src/main/java/net/nymtech/logcathelper/LogCollect.kt
new file mode 100644
index 0000000..4835e42
--- /dev/null
+++ b/logcat_helper/src/main/java/net/nymtech/logcathelper/LogCollect.kt
@@ -0,0 +1,13 @@
+package net.nymtech.logcathelper
+
+import kotlinx.coroutines.flow.Flow
+import net.nymtech.logcathelper.model.LogMessage
+import java.io.File
+
+interface LogCollect {
+ fun start(onLogMessage: ((message: LogMessage) -> Unit)? = null)
+ fun stop()
+ suspend fun getLogFile(): Result
+ val bufferedLogs: Flow
+ val liveLogs: Flow
+}
diff --git a/logcat_helper/src/main/java/net/nymtech/logcathelper/LogcatHelper.kt b/logcat_helper/src/main/java/net/nymtech/logcathelper/LogcatHelper.kt
index c2f8c71..3e6ee15 100644
--- a/logcat_helper/src/main/java/net/nymtech/logcathelper/LogcatHelper.kt
+++ b/logcat_helper/src/main/java/net/nymtech/logcathelper/LogcatHelper.kt
@@ -1,19 +1,277 @@
package net.nymtech.logcathelper
+import android.content.Context
+import android.os.Build
+import androidx.annotation.RequiresApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.withContext
import net.nymtech.logcathelper.model.LogMessage
+import timber.log.Timber
+import java.io.BufferedReader
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.FileOutputStream
+import java.io.FileReader
+import java.io.IOException
+import java.io.InputStreamReader
+import java.io.PrintWriter
+import java.nio.file.Files
+import java.nio.file.Paths
+import java.nio.file.StandardOpenOption
object LogcatHelper {
- fun logs(callback: (input: LogMessage) -> Unit) {
- clear()
- Runtime.getRuntime().exec("logcat -v epoch")
- .inputStream
- .bufferedReader()
- .useLines { lines ->
- lines.forEach { callback(LogMessage.from(it)) }
+
+ private const val MAX_FILE_SIZE = 2097152L // 2MB
+ private const val MAX_FOLDER_SIZE = 10485760L // 10MB
+
+ private object LogcatHelperInit {
+ var maxFileSize: Long = MAX_FILE_SIZE
+ var maxFolderSize: Long = MAX_FOLDER_SIZE
+ var pID: Int = 0
+ var publicAppDirectory = ""
+ var logcatPath = ""
+ }
+
+ fun init(maxFileSize: Long = MAX_FILE_SIZE, maxFolderSize: Long = MAX_FOLDER_SIZE, context: Context): LogCollect {
+ if (maxFileSize > maxFolderSize) {
+ throw IllegalStateException("maxFileSize must be less than maxFolderSize")
+ }
+ synchronized(LogcatHelperInit) {
+ LogcatHelperInit.maxFileSize = maxFileSize
+ LogcatHelperInit.maxFolderSize = maxFolderSize
+ LogcatHelperInit.pID = android.os.Process.myPid()
+ context.getExternalFilesDir(null)?.let {
+ LogcatHelperInit.publicAppDirectory = it.absolutePath
+ LogcatHelperInit.logcatPath = LogcatHelperInit.publicAppDirectory + File.separator + "logs"
+ val logDirectory = File(LogcatHelperInit.logcatPath)
+ if (!logDirectory.exists()) {
+ logDirectory.mkdir()
+ }
}
+ return Logcat
+ }
}
- fun clear() {
- Runtime.getRuntime().exec("logcat -c")
+ internal object Logcat : LogCollect {
+
+ private var logcatReader: LogcatReader? = null
+
+ override fun start(onLogMessage: ((message: LogMessage) -> Unit)?) {
+ logcatReader ?: run {
+ logcatReader = LogcatReader(LogcatHelperInit.pID.toString(), LogcatHelperInit.logcatPath, onLogMessage)
+ }
+ logcatReader?.let { logReader ->
+ if (!logReader.isAlive) logReader.start()
+ }
+ }
+
+ override fun stop() {
+ logcatReader?.stopLogs()
+ logcatReader = null
+ }
+
+ private fun mergeLogs(sourceDir: String, outputFile: File) {
+ val logcatDir = File(sourceDir)
+
+ if (!outputFile.exists()) outputFile.createNewFile()
+ val pw = PrintWriter(outputFile)
+ val logFiles = logcatDir.listFiles()
+
+ logFiles?.sortBy { it.lastModified() }
+
+ logFiles?.forEach { logFile ->
+ val br = BufferedReader(FileReader(logFile))
+
+ var line: String?
+ while (run {
+ line = br.readLine()
+ line
+ } != null
+ ) {
+ pw.println(line)
+ }
+ }
+ pw.flush()
+ pw.close()
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun mergeLogsApi26(sourceDir: String, outputFile: File) {
+ val outputFilePath = Paths.get(outputFile.absolutePath)
+ val logcatPath = Paths.get(sourceDir)
+
+ Files.list(logcatPath)
+ .sorted { o1, o2 ->
+ Files.getLastModifiedTime(o1).compareTo(Files.getLastModifiedTime(o2))
+ }
+ .flatMap(Files::lines)
+ .forEach { line ->
+ Files.write(
+ outputFilePath,
+ (line + System.lineSeparator()).toByteArray(),
+ StandardOpenOption.CREATE,
+ StandardOpenOption.APPEND,
+ )
+ }
+ }
+
+ override suspend fun getLogFile(): Result {
+ stop()
+ return withContext(Dispatchers.IO) {
+ try {
+ val outputDir = File(LogcatHelperInit.publicAppDirectory + File.separator + "output")
+ val outputFile = File(outputDir.absolutePath + File.separator + "logs.txt")
+
+ if (!outputDir.exists()) outputDir.mkdir()
+ if (outputFile.exists()) outputFile.delete()
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ mergeLogsApi26(LogcatHelperInit.logcatPath, outputFile)
+ } else {
+ mergeLogs(LogcatHelperInit.logcatPath, outputFile)
+ }
+ Result.success(outputFile)
+ } catch (e: Exception) {
+ Result.failure(e)
+ } finally {
+ start()
+ }
+ }
+ }
+
+ private val _bufferedLogs = MutableSharedFlow(
+ replay = 10_000,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+ private val _liveLogs = MutableSharedFlow(
+ replay = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+
+ override val bufferedLogs: Flow = _bufferedLogs.asSharedFlow()
+
+ override val liveLogs: Flow = _liveLogs.asSharedFlow()
+
+ private class LogcatReader(
+ pID: String,
+ private val logcatPath: String,
+ private val callback: ((input: LogMessage) -> Unit)?,
+ ) : Thread() {
+ private var logcatProc: Process? = null
+ private var reader: BufferedReader? = null
+ private var mRunning = true
+ private var command = ""
+ private var clearLogCommand = ""
+ private var outputStream: FileOutputStream? = null
+
+ init {
+ try {
+ outputStream = FileOutputStream(createLogFile(logcatPath))
+ } catch (e: FileNotFoundException) {
+ Timber.e(e)
+ }
+
+ command = "logcat -v epoch | grep \"($pID)\""
+ clearLogCommand = "logcat -c"
+ }
+
+ fun stopLogs() {
+ mRunning = false
+ }
+
+ fun clear() {
+ Runtime.getRuntime().exec(clearLogCommand)
+ }
+
+ override fun run() {
+ if (outputStream == null) return
+ try {
+ clear()
+ logcatProc = Runtime.getRuntime().exec(command)
+ reader = BufferedReader(InputStreamReader(logcatProc!!.inputStream), 1024)
+ var line: String? = null
+
+ while (mRunning && run {
+ line = reader!!.readLine()
+ line
+ } != null
+ ) {
+ if (!mRunning) {
+ break
+ }
+ if (line!!.isEmpty()) {
+ continue
+ }
+
+ if (outputStream!!.channel.size() >= LogcatHelperInit.maxFileSize) {
+ outputStream!!.close()
+ outputStream = FileOutputStream(createLogFile(logcatPath))
+ }
+ if (getFolderSize(logcatPath) >= LogcatHelperInit.maxFolderSize) {
+ deleteOldestFile(logcatPath)
+ }
+ line?.let { text ->
+ outputStream!!.write((text + System.lineSeparator()).toByteArray())
+ try {
+ val logMessage = LogMessage.from(text)
+ _bufferedLogs.tryEmit(logMessage)
+ _liveLogs.tryEmit(logMessage)
+ callback?.let {
+ it(logMessage)
+ }
+ } catch (e: Exception) {
+ Timber.e(e)
+ }
+ }
+ }
+ } catch (e: IOException) {
+ Timber.e(e)
+ } finally {
+ logcatProc?.destroy()
+ logcatProc = null
+
+ try {
+ reader?.close()
+ outputStream?.close()
+ reader = null
+ outputStream = null
+ } catch (e: IOException) {
+ Timber.e(e)
+ }
+ }
+ }
+
+ private fun getFolderSize(path: String): Long {
+ File(path).run {
+ var size = 0L
+ if (this.isDirectory && this.listFiles() != null) {
+ for (file in this.listFiles()!!) {
+ size += getFolderSize(file.absolutePath)
+ }
+ } else {
+ size = this.length()
+ }
+ return size
+ }
+ }
+
+ private fun createLogFile(dir: String): File {
+ return File(dir, "logcat_" + System.currentTimeMillis() + ".txt")
+ }
+
+ private fun deleteOldestFile(path: String) {
+ val directory = File(path)
+ if (directory.isDirectory) {
+ directory.listFiles()?.toMutableList()?.run {
+ this.sortBy { it.lastModified() }
+ this.first().delete()
+ }
+ }
+ }
+ }
}
}
diff --git a/logcat_helper/src/main/java/net/nymtech/logcathelper/model/LogMessage.kt b/logcat_helper/src/main/java/net/nymtech/logcathelper/model/LogMessage.kt
index 366ce4f..adf2120 100644
--- a/logcat_helper/src/main/java/net/nymtech/logcathelper/model/LogMessage.kt
+++ b/logcat_helper/src/main/java/net/nymtech/logcathelper/model/LogMessage.kt
@@ -3,7 +3,7 @@ package net.nymtech.logcathelper.model
import java.time.Instant
data class LogMessage(
- val time: Instant,
+ val time: String,
val pid: String,
val tid: String,
val level: LogLevel,
@@ -18,7 +18,7 @@ data class LogMessage(
fun from(logcatLine: String): LogMessage {
return if (logcatLine.contains("---------")) {
LogMessage(
- Instant.now(),
+ Instant.now().toString(),
"0",
"0",
LogLevel.VERBOSE,
@@ -31,7 +31,7 @@ data class LogMessage(
val epochParts = parts[0].split(".").map { it.toLong() }
val message = parts.subList(5, parts.size).joinToString(" ")
LogMessage(
- Instant.ofEpochSecond(epochParts[0], epochParts[1]),
+ Instant.ofEpochSecond(epochParts[0], epochParts[1]).toString(),
parts[1],
parts[2],
LogLevel.fromSignifier(parts[3]),
diff --git a/nym_vpn_client/src/main/java/net/nymtech/vpn/NymApi.kt b/nym_vpn_client/src/main/java/net/nymtech/vpn/NymApi.kt
index b37da7c..11d9059 100644
--- a/nym_vpn_client/src/main/java/net/nymtech/vpn/NymApi.kt
+++ b/nym_vpn_client/src/main/java/net/nymtech/vpn/NymApi.kt
@@ -1,14 +1,17 @@
package net.nymtech.vpn
-import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import net.nymtech.vpn.model.Country
import net.nymtech.vpn.model.Environment
import nym_vpn_lib.getGatewayCountries
-class NymApi(private val environment: Environment) {
+class NymApi(
+ private val environment: Environment,
+ private val ioDispatcher: CoroutineDispatcher,
+) {
suspend fun gateways(exitOnly: Boolean): Set {
- return withContext(Dispatchers.IO) {
+ return withContext(ioDispatcher) {
getGatewayCountries(environment.apiUrl, environment.explorerUrl, environment.harbourMasterUrl, exitOnly).map {
Country(isoCode = it.twoLetterIsoCountryCode)
}.toSet()
@@ -16,7 +19,7 @@ class NymApi(private val environment: Environment) {
}
suspend fun getLowLatencyEntryCountry(): Country {
- return withContext(Dispatchers.IO) {
+ return withContext(ioDispatcher) {
Country(
isoCode =
nym_vpn_lib.getLowLatencyEntryCountry(
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 6719126..7f394a7 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
@@ -3,18 +3,15 @@ package net.nymtech.vpn
import android.content.Context
import android.content.Intent
import android.net.VpnService
-import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.buffer
-import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import net.nymtech.logcathelper.LogcatHelper
import net.nymtech.logcathelper.model.LogLevel
import net.nymtech.vpn.model.VpnClientState
@@ -32,11 +29,12 @@ import nym_vpn_lib.FfiException
import nym_vpn_lib.VpnConfig
import nym_vpn_lib.checkCredential
import nym_vpn_lib.runVpn
+import nym_vpn_lib.stopVpn
import timber.log.Timber
import java.time.Instant
-import kotlin.coroutines.coroutineContext
object NymVpnClient {
+
private object NymVpnClientInit {
lateinit var entryPoint: EntryPoint
lateinit var exitPoint: ExitPoint
@@ -69,46 +67,62 @@ object NymVpnClient {
}
internal object NymVpn : VpnClient {
+ private val ioDispatcher = Dispatchers.IO
+
override var entryPoint: EntryPoint = NymVpnClientInit.entryPoint
override var exitPoint: ExitPoint = NymVpnClientInit.exitPoint
override var mode: VpnMode = NymVpnClientInit.mode
private val environment: Environment = NymVpnClientInit.environment
- private var job: Job? = null
+ private var logsJob: Job? = null
+ private var statsJob: Job? = null
private val _state = MutableStateFlow(VpnClientState())
override val stateFlow: Flow = _state.asStateFlow()
- override fun validateCredential(credential: String): Result {
- return try {
- val expiry = checkCredential(credential)
- Result.success(expiry)
- } catch (_: FfiException) {
- Result.failure(InvalidCredentialException("Credential invalid or expired"))
+ override suspend fun validateCredential(credential: String): Result {
+ return withContext(ioDispatcher) {
+ try {
+ val expiry = checkCredential(credential)
+ Result.success(expiry)
+ } catch (_: FfiException) {
+ Result.failure(InvalidCredentialException("Credential invalid or expired"))
+ }
}
}
- @Throws(InvalidCredentialException::class)
- override suspend fun start(context: Context, credential: String, foreground: Boolean) {
- validateCredential(credential).onFailure {
- throw it
- }
- clearErrorStatus()
- with(CoroutineScope(coroutineContext)) {
- launch {
- collectLogStatus()
+ override suspend fun start(context: Context, credential: String, foreground: Boolean): Result {
+ return withContext(ioDispatcher) {
+ validateCredential(credential).onFailure {
+ return@withContext Result.failure(it)
}
- launch {
- startConnectionTimer()
+ if (_state.value.vpnState == VpnState.Down) {
+ setVpnState(VpnState.Connecting.InitializingClient)
+ clearErrorStatus()
+ if (foreground) ServiceManager.startVpnServiceForeground(context) else ServiceManager.startVpnService(context)
+ }
+ Result.success(Unit)
+ }
+ }
+
+ override suspend fun stop(context: Context, foreground: Boolean) {
+ withContext(ioDispatcher) {
+ clearStatisticState()
+ setVpnState(VpnState.Disconnecting)
+ try {
+ stopVpn()
+ } catch (e: FfiException) {
+ Timber.e(e)
}
+ delay(1000)
+ handleClientShutdown(context)
}
- if (foreground) ServiceManager.startVpnServiceForeground(context) else ServiceManager.startVpnService(context)
}
- @Synchronized
- override fun stop(context: Context, foreground: Boolean) {
+ private fun handleClientShutdown(context: Context) {
ServiceManager.stopVpnService(context)
- cancelStatistics()
+ clearStatisticState()
+ cancelJobs()
}
override fun prepare(context: Context): Intent? {
@@ -118,6 +132,11 @@ object NymVpnClient {
return _state.value
}
+ private fun cancelJobs() {
+ statsJob?.cancel()
+ logsJob?.cancel()
+ }
+
private fun clearErrorStatus() {
_state.update {
it.copy(
@@ -157,10 +176,12 @@ object NymVpnClient {
}
internal fun setVpnState(state: VpnState) {
- _state.update {
- it.copy(
- vpnState = state,
- )
+ if (state != _state.value.vpnState) {
+ _state.update {
+ it.copy(
+ vpnState = state,
+ )
+ }
}
}
@@ -169,43 +190,42 @@ object NymVpnClient {
else -> false
}
- internal fun connect() {
- try {
- runVpn(
- VpnConfig(
- environment.apiUrl,
- environment.explorerUrl,
- entryPoint,
- exitPoint,
- isTwoHop(mode),
- null,
- ),
- )
- } catch (e: FfiException) {
- Timber.e(e)
- setErrorState(ErrorState.GatewayLookupFailure)
- handleErrorShutdown()
- }
- }
+ internal suspend fun connect(context: Context) {
+ cancelJobs()
+ withContext(ioDispatcher) {
+ logsJob = launch(ioDispatcher) {
+ monitorLogs(context)
+ }
- private fun cancelStatistics() {
- job?.cancel()
- clearStatisticState()
- }
+ statsJob = launch {
+ startConnectionTimer()
+ }
- private suspend fun collectLogStatus() {
- callbackFlow {
- LogcatHelper.logs {
- if (it.level != LogLevel.DEBUG) {
- trySend(it)
- }
+ try {
+ runVpn(
+ VpnConfig(
+ environment.apiUrl,
+ environment.explorerUrl,
+ entryPoint,
+ exitPoint,
+ isTwoHop(mode),
+ null,
+ ),
+ )
+ } catch (e: FfiException) {
+ Timber.e(e)
+ setErrorState(ErrorState.GatewayLookupFailure)
+ handleClientShutdown(context)
}
- awaitClose { cancel() }
- }.buffer(capacity = 100).safeCollect {
+ }
+ }
+
+ private suspend fun monitorLogs(context: Context) {
+ LogcatHelper.init(context = context).liveLogs.safeCollect {
if (it.tag.contains(Constants.NYM_VPN_LIB_TAG)) {
when (it.level) {
LogLevel.ERROR -> {
- parseErrorMessageForState(it.message)
+ parseErrorMessageForState(it.message) { handleClientShutdown(context) }
}
LogLevel.INFO -> {
parseInfoMessageForState(it.message)
@@ -217,20 +237,16 @@ object NymVpnClient {
}
private suspend fun startConnectionTimer() {
- var seconds = 0L
- do {
- if (_state.value.vpnState == VpnState.Up) {
- setStatisticState(seconds)
- seconds++
- }
- delay(1000)
- } while (true)
- }
-
- private fun handleErrorShutdown() {
- setVpnState(VpnState.Down)
- NymVpnService.service?.get()?.stopSelf()
- cancelStatistics()
+ withContext(ioDispatcher) {
+ var seconds = 0L
+ do {
+ if (_state.value.vpnState == VpnState.Up) {
+ setStatisticState(seconds)
+ seconds++
+ }
+ delay(1000)
+ } while (true)
+ }
}
private fun parseInfoMessageForState(message: String) {
@@ -246,7 +262,7 @@ object NymVpnClient {
}
}
- private fun parseErrorMessageForState(message: String) {
+ private fun parseErrorMessageForState(message: String, onError: () -> Unit) {
with(message) {
val errorState = when {
contains("failed to lookup described gateways") -> ErrorState.GatewayLookupFailure
@@ -257,7 +273,7 @@ object NymVpnClient {
}
errorState?.let {
setErrorState(it)
- handleErrorShutdown()
+ onError()
}
}
}
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 5a0e816..2965aa3 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
@@ -5,10 +5,8 @@ import android.net.VpnService
import android.os.Build
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
-import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.newSingleThreadContext
import net.nymtech.vpn.model.VpnState
@@ -16,10 +14,7 @@ import net.nymtech.vpn.tun_provider.TunConfig
import net.nymtech.vpn.util.Action
import net.nymtech.vpn.util.Constants
import net.nymtech.vpn_client.BuildConfig
-import nym_vpn_lib.FfiException
-import nym_vpn_lib.stopVpn
import timber.log.Timber
-import java.lang.ref.SoftReference
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
@@ -30,14 +25,11 @@ class NymVpnService : VpnService() {
System.loadLibrary(Constants.NYM_VPN_LIB)
Timber.i("Loaded native library in service")
}
- var service: SoftReference? = null
}
@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)
private val vpnThread = newSingleThreadContext("VpnThread")
- private val scope = CoroutineScope(Dispatchers.IO)
-
private var activeTunStatus: CreateTunResult? = null
// Once we make sure Rust library doesn't close the fd first, we should re-use this code for closing fd,
@@ -78,7 +70,7 @@ class NymVpnService : VpnService() {
}
}
Action.STOP.name, Action.STOP_FOREGROUND.name -> {
- stopService()
+ stopSelf()
}
}
return super.onStartCommand(intent, flags, startId)
@@ -87,16 +79,14 @@ class NymVpnService : VpnService() {
private fun startService() {
synchronized(this) {
CoroutineScope(vpnThread).launch {
- NymVpnClient.NymVpn.setVpnState(VpnState.Connecting.InitializingClient)
- val logLevel = if (BuildConfig.DEBUG) "debug" else "info"
+ val logLevel = if (BuildConfig.DEBUG) "info" else "info"
initVPN(this@NymVpnService, logLevel)
- NymVpnClient.NymVpn.connect()
+ NymVpnClient.NymVpn.connect(this@NymVpnService)
}
}
}
override fun onCreate() {
- service = SoftReference(this)
connectivityListener.register(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager.createNotificationChannel(this@NymVpnService)
@@ -105,28 +95,12 @@ class NymVpnService : VpnService() {
startForeground(123, notification)
}
- private fun stopService() {
- synchronized(this) {
- scope.launch {
- try {
- NymVpnClient.NymVpn.setVpnState(VpnState.Disconnecting)
- stopVpn()
- } catch (e: FfiException) {
- Timber.e(e)
- }
- delay(1000)
- stopSelf()
- }
- }
- }
-
override fun onDestroy() {
connectivityListener.unregister()
NymVpnClient.NymVpn.setVpnState(VpnState.Down)
stopForeground(STOP_FOREGROUND_REMOVE)
Timber.i("VpnService destroyed")
vpnThread.cancel()
- scope.cancel()
}
fun getTun(config: TunConfig): CreateTunResult {
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 f40d53c..f545d1a 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
@@ -16,12 +16,12 @@ interface VpnClient {
var exitPoint: ExitPoint
var mode: VpnMode
- fun validateCredential(credential: String): Result
+ suspend fun validateCredential(credential: String): Result
@Throws(InvalidCredentialException::class)
- suspend fun start(context: Context, credential: String, foreground: Boolean = false)
+ suspend fun start(context: Context, credential: String, foreground: Boolean = false): Result
- fun stop(context: Context, foreground: Boolean = false)
+ suspend fun stop(context: Context, foreground: Boolean = false)
fun prepare(context: Context): Intent?