From d2f5f94bbea4006d0e4610eff3479101ad416d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B6ransson?= Date: Thu, 19 Dec 2024 13:20:26 +0100 Subject: [PATCH] Migrate from SharedPreferences to DataStore --- .../net/mullvad/mullvadvpn/di/UiModule.kt | 26 ++++++++------- .../repository/PrivacyDisclaimerRepository.kt | 15 --------- .../repository/UserPreferencesMigration.kt | 31 ++++++++++++++++++ .../repository/UserPreferencesRepository.kt | 32 +++++++++++++++++++ .../repository/UserPreferencesSerializer.kt | 21 ++++++++++++ .../net/mullvad/mullvadvpn/ui/MainActivity.kt | 14 ++++---- .../viewmodel/PrivacyDisclaimerViewModel.kt | 7 ++-- .../mullvadvpn/viewmodel/SplashViewModel.kt | 6 ++-- 8 files changed, 113 insertions(+), 39 deletions(-) delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/PrivacyDisclaimerRepository.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesMigration.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesSerializer.kt diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index b111db10b207..650ee67eaa1b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -2,8 +2,9 @@ package net.mullvad.mullvadvpn.di import android.content.ComponentName import android.content.Context -import android.content.SharedPreferences import android.content.pm.PackageManager +import androidx.datastore.core.DataStore +import androidx.datastore.dataStore import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import net.mullvad.mullvadvpn.BuildConfig @@ -20,7 +21,6 @@ import net.mullvad.mullvadvpn.repository.ChangelogRepository import net.mullvad.mullvadvpn.repository.CustomListsRepository import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.repository.NewDeviceRepository -import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository import net.mullvad.mullvadvpn.repository.ProblemReportRepository import net.mullvad.mullvadvpn.repository.RelayListFilterRepository import net.mullvad.mullvadvpn.repository.RelayListRepository @@ -28,6 +28,10 @@ import net.mullvad.mullvadvpn.repository.RelayOverridesRepository import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.repository.SplashCompleteRepository import net.mullvad.mullvadvpn.repository.SplitTunnelingRepository +import net.mullvad.mullvadvpn.repository.UserPreferences +import net.mullvad.mullvadvpn.repository.UserPreferencesMigration +import net.mullvad.mullvadvpn.repository.UserPreferencesRepository +import net.mullvad.mullvadvpn.repository.UserPreferencesSerializer import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository import net.mullvad.mullvadvpn.ui.MainActivity import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository @@ -99,16 +103,13 @@ import net.mullvad.mullvadvpn.viewmodel.location.SearchLocationViewModel import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationListViewModel import net.mullvad.mullvadvpn.viewmodel.location.SelectLocationViewModel import org.apache.commons.validator.routines.InetAddressValidator -import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel import org.koin.core.qualifier.named import org.koin.dsl.module val uiModule = module { - single(named(APP_PREFERENCES_NAME)) { - androidApplication().getSharedPreferences(APP_PREFERENCES_NAME, Context.MODE_PRIVATE) - } + single> { androidContext().userPreferencesStore } single { androidContext().packageManager } single(named(SELF_PACKAGE_NAME)) { androidContext().packageName } @@ -126,11 +127,7 @@ val uiModule = module { single { androidContext().contentResolver } single { ChangelogRepository(get()) } - single { - PrivacyDisclaimerRepository( - androidContext().getSharedPreferences(APP_PREFERENCES_NAME, Context.MODE_PRIVATE) - ) - } + single { UserPreferencesRepository(get()) } single { SettingsRepository(get()) } single { MullvadProblemReport(get()) } single { RelayOverridesRepository(get()) } @@ -272,3 +269,10 @@ val uiModule = module { const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" const val APP_PREFERENCES_NAME = "${BuildConfig.APPLICATION_ID}.app_preferences" const val BOOT_COMPLETED_RECEIVER_COMPONENT_NAME = "BOOT_COMPLETED_RECEIVER_COMPONENT_NAME" + +private val Context.userPreferencesStore: DataStore by + dataStore( + fileName = APP_PREFERENCES_NAME, + serializer = UserPreferencesSerializer, + produceMigrations = UserPreferencesMigration::migrations, + ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/PrivacyDisclaimerRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/PrivacyDisclaimerRepository.kt deleted file mode 100644 index db1ad220e346..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/PrivacyDisclaimerRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package net.mullvad.mullvadvpn.repository - -import android.content.SharedPreferences - -private const val IS_PRIVACY_DISCLOSURE_ACCEPTED_KEY = "is_privacy_disclosure_accepted" - -class PrivacyDisclaimerRepository(private val sharedPreferences: SharedPreferences) { - fun hasAcceptedPrivacyDisclosure(): Boolean { - return sharedPreferences.getBoolean(IS_PRIVACY_DISCLOSURE_ACCEPTED_KEY, false) - } - - fun setPrivacyDisclosureAccepted() { - sharedPreferences.edit().putBoolean(IS_PRIVACY_DISCLOSURE_ACCEPTED_KEY, true).apply() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesMigration.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesMigration.kt new file mode 100644 index 000000000000..c92d9d393ac4 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesMigration.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.repository + +import android.content.Context +import androidx.datastore.core.DataMigration +import androidx.datastore.migrations.SharedPreferencesMigration +import androidx.datastore.migrations.SharedPreferencesView +import net.mullvad.mullvadvpn.di.APP_PREFERENCES_NAME + +private const val IS_PRIVACY_DISCLOSURE_ACCEPTED_KEY_SHARED_PREF_KEY = + "is_privacy_disclosure_accepted" + +data object UserPreferencesMigration { + fun migrations(context: Context): List> = + listOf( + SharedPreferencesMigration( + context, + sharedPreferencesName = APP_PREFERENCES_NAME, + keysToMigrate = setOf(IS_PRIVACY_DISCLOSURE_ACCEPTED_KEY_SHARED_PREF_KEY), + ) { sharedPrefs: SharedPreferencesView, currentData: UserPreferences -> + val privacyDisclosureAccepted = + sharedPrefs.getBoolean( + IS_PRIVACY_DISCLOSURE_ACCEPTED_KEY_SHARED_PREF_KEY, + false, + ) + currentData + .toBuilder() + .setIsPrivacyDisclosureAccepted(privacyDisclosureAccepted) + .build() + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt new file mode 100644 index 000000000000..f3e6a72b64cf --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesRepository.kt @@ -0,0 +1,32 @@ +package net.mullvad.mullvadvpn.repository + +import androidx.datastore.core.DataStore +import co.touchlab.kermit.Logger +import java.io.IOException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first + +class UserPreferencesRepository(private val userPreferences: DataStore) { + + // Note: this should not be made into a StateFlow. See: + // https://developer.android.com/reference/kotlin/androidx/datastore/core/DataStore#data() + val preferencesFlow: Flow = + userPreferences.data.catch { exception -> + // dataStore.data throws an IOException when an error is encountered when reading data + if (exception is IOException) { + Logger.e("Error reading user preferences file, falling back to default.", exception) + emit(UserPreferences.getDefaultInstance()) + } else { + throw exception + } + } + + suspend fun preferences(): UserPreferences = preferencesFlow.first() + + suspend fun setPrivacyDisclosureAccepted() { + userPreferences.updateData { prefs -> + prefs.toBuilder().setIsPrivacyDisclosureAccepted(true).build() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesSerializer.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesSerializer.kt new file mode 100644 index 000000000000..97348fd0cc01 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/UserPreferencesSerializer.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.repository + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream + +object UserPreferencesSerializer : Serializer { + override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): UserPreferences { + try { + return UserPreferences.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto", exception) + } + } + + override suspend fun writeTo(t: UserPreferences, output: OutputStream) = t.writeTo(output) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt index 4007b09ecd1f..76ec06d6cf6f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt @@ -33,8 +33,8 @@ import net.mullvad.mullvadvpn.lib.endpoint.getApiEndpointConfigurationExtras import net.mullvad.mullvadvpn.lib.model.PrepareError import net.mullvad.mullvadvpn.lib.model.Prepared import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository import net.mullvad.mullvadvpn.repository.SplashCompleteRepository +import net.mullvad.mullvadvpn.repository.UserPreferencesRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.viewmodel.MullvadAppViewModel import org.koin.android.ext.android.inject @@ -55,7 +55,7 @@ class MainActivity : ComponentActivity(), AndroidScopeComponent { private val apiEndpointFromIntentHolder by inject() private val mullvadAppViewModel by inject() - private val privacyDisclaimerRepository by inject() + private val userPreferencesRepository by inject() private val serviceConnectionManager by inject() private val splashCompleteRepository by inject() private val managementService by inject() @@ -93,7 +93,7 @@ class MainActivity : ComponentActivity(), AndroidScopeComponent { // https://medium.com/@lepicekmichal/android-background-service-without-hiccup-501e4479110f lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { + if (userPreferencesRepository.preferences().isPrivacyDisclosureAccepted) { bindService() } } @@ -103,7 +103,7 @@ class MainActivity : ComponentActivity(), AndroidScopeComponent { override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) lifecycleScope.launch { - if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { + if (userPreferencesRepository.preferences().isPrivacyDisclosureAccepted) { // If service is to be started wait for it to be connected before dismissing Splash // screen managementService.connectionState @@ -121,8 +121,10 @@ class MainActivity : ComponentActivity(), AndroidScopeComponent { override fun onStop() { super.onStop() - if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { - serviceConnectionManager.unbind() + lifecycleScope.launch { + if (userPreferencesRepository.preferences().isPrivacyDisclosureAccepted) { + serviceConnectionManager.unbind() + } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt index d2500bc94d30..11800791d26f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt @@ -5,18 +5,17 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository +import net.mullvad.mullvadvpn.repository.UserPreferencesRepository data class PrivacyDisclaimerViewState(val isStartingService: Boolean, val isPlayBuild: Boolean) class PrivacyDisclaimerViewModel( - private val privacyDisclaimerRepository: PrivacyDisclaimerRepository, + private val userPreferencesRepository: UserPreferencesRepository, isPlayBuild: Boolean, ) : ViewModel() { @@ -40,8 +39,8 @@ class PrivacyDisclaimerViewModel( val uiSideEffect = _uiSideEffect.receiveAsFlow() fun setPrivacyDisclosureAccepted() { - privacyDisclaimerRepository.setPrivacyDisclosureAccepted() viewModelScope.launch { + userPreferencesRepository.setPrivacyDisclosureAccepted() if (!_isStartingService.value) { _isStartingService.update { true } _uiSideEffect.send(PrivacyDisclaimerUiSideEffect.StartService) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt index a196d4ae90fa..0ed85c94cd8b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt @@ -16,13 +16,13 @@ import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_TIMEOUT_MS import net.mullvad.mullvadvpn.lib.model.DeviceState import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.DeviceRepository -import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository import net.mullvad.mullvadvpn.repository.SplashCompleteRepository +import net.mullvad.mullvadvpn.repository.UserPreferencesRepository data class SplashScreenState(val splashComplete: Boolean = false) class SplashViewModel( - private val privacyDisclaimerRepository: PrivacyDisclaimerRepository, + private val userPreferencesRepository: UserPreferencesRepository, private val accountRepository: AccountRepository, private val deviceRepository: DeviceRepository, private val splashCompleteRepository: SplashCompleteRepository, @@ -37,7 +37,7 @@ class SplashViewModel( val uiState: StateFlow = _uiState private suspend fun getStartDestination(): SplashUiSideEffect { - if (!privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { + if (!userPreferencesRepository.preferences().isPrivacyDisclosureAccepted) { return SplashUiSideEffect.NavigateToPrivacyDisclaimer }