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?