From d617e7d9c01cebdd0c40d1eaae6c87627fe9b049 Mon Sep 17 00:00:00 2001 From: Zane Schepke Date: Mon, 27 May 2024 03:23:06 -0400 Subject: [PATCH] feat: improve logging and refactor Added improved logging with both file logging and live flow for ui to display. Refactored file utilities. Added coroutine injection and application scope injection. Cleaned up redundant coroutine declarations. Improve quick tile state management and efficiency. Refactored vpn lib and vpn service stop. --- app/src/main/AndroidManifest.xml | 194 ++++++------ .../main/java/net/nymtech/nymvpn/NymVpn.kt | 39 +++ .../nymvpn/data/datastore/DataStoreManager.kt | 39 ++- .../datastore/DataStoreSettingsRepository.kt | 4 +- .../datastore/SecretsPreferencesRepository.kt | 24 +- .../nymtech/nymvpn/data/domain/Settings.kt | 2 +- .../module/{Android.kt => ApiQualifiers.kt} | 4 + .../net/nymtech/nymvpn/module/AppModule.kt | 122 ++++++++ .../nymvpn/module/CoroutineQualifiers.kt | 27 ++ .../module/CoroutinesDispatchersModule.kt | 29 ++ .../net/nymtech/nymvpn/module/DataModule.kt | 9 +- .../nymtech/nymvpn/module/ManagerModule.kt | 7 + .../java/net/nymtech/nymvpn/module/Native.kt | 7 - .../nymtech/nymvpn/module/ServiceModule.kt | 81 +---- .../nymtech/nymvpn/receiver/BootReceiver.kt | 8 +- .../nymvpn/service/AlwaysOnVpnService.kt | 12 +- .../nymvpn/service/tile/VpnQuickTile.kt | 91 +++--- .../nymvpn/service/vpn/NymVpnManager.kt | 21 +- .../nymtech/nymvpn/service/vpn/VpnManager.kt | 6 +- .../java/net/nymtech/nymvpn/ui/AppUiState.kt | 1 - .../net/nymtech/nymvpn/ui/AppViewModel.kt | 63 +--- .../net/nymtech/nymvpn/ui/MainActivity.kt | 25 +- .../java/net/nymtech/nymvpn/ui/Navigation.kt | 3 - .../net/nymtech/nymvpn/ui/ShortcutActivity.kt | 30 +- .../net/nymtech/nymvpn/ui/SplashActivity.kt | 51 ++-- .../nymvpn/ui/common/navigation/NavBar.kt | 5 +- .../nymvpn/ui/screens/hop/HopViewModel.kt | 7 +- .../nymvpn/ui/screens/main/MainViewModel.kt | 8 +- .../ui/screens/settings/SettingsViewModel.kt | 2 +- .../credential/CredentialViewModel.kt | 7 +- .../settings/display/DisplayViewModel.kt | 2 +- .../settings/legal/licenses/LicenseParser.kt | 16 +- .../settings/legal/licenses/LicensesScreen.kt | 2 +- .../legal/licenses/LicensesViewModel.kt | 16 +- .../ui/screens/settings/logs/LogsScreen.kt | 29 +- .../ui/screens/settings/logs/LogsViewModel.kt | 54 ++++ .../java/net/nymtech/nymvpn/util/Constants.kt | 1 - .../net/nymtech/nymvpn/util/Extensions.kt | 72 +++++ .../java/net/nymtech/nymvpn/util/FileUtils.kt | 76 +++-- app/src/main/res/values/strings.xml | 1 + buildSrc/src/main/kotlin/Constants.kt | 4 +- config/detekt.yml | 2 + .../android/en-US/changelogs/10400.txt | 4 + gradle/libs.versions.toml | 1 - logcat_helper/build.gradle.kts | 2 + .../net/nymtech/logcathelper/LogCollect.kt | 13 + .../net/nymtech/logcathelper/LogcatHelper.kt | 276 +++++++++++++++++- .../nymtech/logcathelper/model/LogMessage.kt | 6 +- .../src/main/java/net/nymtech/vpn/NymApi.kt | 11 +- .../main/java/net/nymtech/vpn/NymVpnClient.kt | 174 ++++++----- .../java/net/nymtech/vpn/NymVpnService.kt | 32 +- .../main/java/net/nymtech/vpn/VpnClient.kt | 6 +- 52 files changed, 1164 insertions(+), 564 deletions(-) rename app/src/main/java/net/nymtech/nymvpn/module/{Android.kt => ApiQualifiers.kt} (65%) create mode 100644 app/src/main/java/net/nymtech/nymvpn/module/AppModule.kt create mode 100644 app/src/main/java/net/nymtech/nymvpn/module/CoroutineQualifiers.kt create mode 100644 app/src/main/java/net/nymtech/nymvpn/module/CoroutinesDispatchersModule.kt delete mode 100644 app/src/main/java/net/nymtech/nymvpn/module/Native.kt create mode 100644 app/src/main/java/net/nymtech/nymvpn/ui/screens/settings/logs/LogsViewModel.kt create mode 100644 fastlane/metadata/android/en-US/changelogs/10400.txt create mode 100644 logcat_helper/src/main/java/net/nymtech/logcathelper/LogCollect.kt 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?