From a1d9f926cb8775c4542825152bb3714c40a24209 Mon Sep 17 00:00:00 2001 From: Phil Oliver <3497406+poliver@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:52:09 -0400 Subject: [PATCH] Pref fixes (#3175) --- app/build.gradle.kts | 2 + .../java/com/geeksville/mesh/MainActivity.kt | 13 ++-- .../java/com/geeksville/mesh/model/UIState.kt | 8 ++- .../mesh/ui/settings/SettingsViewModel.kt | 6 +- .../core/datastore/UiPreferencesDataSource.kt | 68 +++++++++++++++++++ .../core/datastore/di/DataStoreModule.kt | 12 +++- .../org/meshtastic/core/prefs/ui/UiPrefs.kt | 19 ------ 7 files changed, 99 insertions(+), 29 deletions(-) create mode 100644 core/datastore/src/main/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4220da66a..90bac9979 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -139,6 +139,8 @@ android { release { if (keystoreProperties["storeFile"] != null) { signingConfig = signingConfigs.named("release").get() + } else { + signingConfig = signingConfigs.getByName("debug") } } } diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 74e7c76b7..7a1eee220 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalView import androidx.core.net.toUri import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope import com.geeksville.mesh.android.GeeksvilleApplication import com.geeksville.mesh.android.Logging import com.geeksville.mesh.model.UIViewModel @@ -48,8 +49,9 @@ import com.geeksville.mesh.ui.common.theme.MODE_DYNAMIC import com.geeksville.mesh.ui.intro.AppIntroductionScreen import com.geeksville.mesh.ui.sharing.toSharedContact import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import org.meshtastic.core.datastore.UiPreferencesDataSource import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI -import org.meshtastic.core.prefs.ui.UiPrefs import javax.inject.Inject @AndroidEntryPoint @@ -61,7 +63,7 @@ class MainActivity : // This is aware of the Activity lifecycle and handles binding to the mesh service. @Inject internal lateinit var meshServiceClient: MeshServiceClient - @Inject internal lateinit var uiPrefs: UiPrefs + @Inject internal lateinit var uiPreferencesDataSource: UiPreferencesDataSource override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() @@ -77,8 +79,11 @@ class MainActivity : super.onCreate(savedInstanceState) if (savedInstanceState == null) { - if (uiPrefs.appIntroCompleted) { - (application as GeeksvilleApplication).askToRate(this) + lifecycleScope.launch { + val appIntroCompleted = uiPreferencesDataSource.appIntroCompleted.value + if (appIntroCompleted) { + (application as GeeksvilleApplication).askToRate(this@MainActivity) + } } } diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index ee6f228a5..ae904bca8 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -81,6 +81,7 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.meshtastic.core.datastore.UiPreferencesDataSource import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.prefs.ui.UiPrefs import org.meshtastic.core.strings.R @@ -188,11 +189,12 @@ constructor( private val quickChatActionRepository: QuickChatActionRepository, firmwareReleaseRepository: FirmwareReleaseRepository, private val uiPrefs: UiPrefs, + private val uiPreferencesDataSource: UiPreferencesDataSource, private val meshServiceNotifications: MeshServiceNotifications, ) : ViewModel(), Logging { - val theme: StateFlow = uiPrefs.themeFlow + val theme: StateFlow = uiPreferencesDataSource.theme private val _lastTraceRouteTime = MutableStateFlow(null) val lastTraceRouteTime: StateFlow = _lastTraceRouteTime.asStateFlow() @@ -822,9 +824,9 @@ constructor( nodeFilterText.value = text } - val appIntroCompleted: StateFlow = uiPrefs.appIntroCompletedFlow + val appIntroCompleted: StateFlow = uiPreferencesDataSource.appIntroCompleted fun onAppIntroCompleted() { - uiPrefs.appIntroCompleted = true + uiPreferencesDataSource.setAppIntroCompleted(true) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsViewModel.kt index a73c5a599..c00517702 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsViewModel.kt @@ -47,6 +47,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.meshtastic.core.datastore.UiPreferencesDataSource import org.meshtastic.core.prefs.ui.UiPrefs import java.io.BufferedWriter import java.io.FileNotFoundException @@ -65,6 +66,7 @@ constructor( private val nodeRepository: NodeRepository, private val meshLogRepository: MeshLogRepository, private val uiPrefs: UiPrefs, + private val uiPreferencesDataSource: UiPreferencesDataSource, ) : ViewModel(), Logging { val myNodeInfo: StateFlow = nodeRepository.myNodeInfo @@ -109,11 +111,11 @@ constructor( } fun setTheme(theme: Int) { - uiPrefs.theme = theme + uiPreferencesDataSource.setTheme(theme) } fun showAppIntro() { - uiPrefs.appIntroCompleted = false + uiPreferencesDataSource.setAppIntroCompleted(false) } fun unlockExcludedModules() { diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt new file mode 100644 index 000000000..ef47432dd --- /dev/null +++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.core.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +internal const val KEY_APP_INTRO_COMPLETED = "app_intro_completed" +internal const val KEY_THEME = "theme" + +@Singleton +class UiPreferencesDataSource @Inject constructor(private val dataStore: DataStore) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + val appIntroCompleted: StateFlow = dataStore.prefStateFlow(key = APP_INTRO_COMPLETED, default = false) + + // Default value for AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + val theme: StateFlow = dataStore.prefStateFlow(key = THEME, default = -1) + + fun setAppIntroCompleted(completed: Boolean) { + dataStore.setPref(key = APP_INTRO_COMPLETED, value = completed) + } + + fun setTheme(value: Int) { + dataStore.setPref(key = THEME, value = value) + } + + private fun DataStore.prefStateFlow(key: Preferences.Key, default: T): StateFlow = + data.map { it[key] ?: default }.stateIn(scope = scope, started = SharingStarted.Lazily, initialValue = default) + + private fun DataStore.setPref(key: Preferences.Key, value: T) { + scope.launch { edit { it[key] = value } } + } + + private companion object { + val APP_INTRO_COMPLETED = booleanPreferencesKey(KEY_APP_INTRO_COMPLETED) + val THEME = intPreferencesKey(KEY_THEME) + } +} diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt index b92978590..2438a2759 100644 --- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt @@ -38,6 +38,8 @@ import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import org.meshtastic.core.datastore.KEY_APP_INTRO_COMPLETED +import org.meshtastic.core.datastore.KEY_THEME import org.meshtastic.core.datastore.serializer.ChannelSetSerializer import org.meshtastic.core.datastore.serializer.LocalConfigSerializer import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer @@ -53,7 +55,15 @@ object DataStoreModule { fun providePreferencesDataStore(@ApplicationContext appContext: Context): DataStore = PreferenceDataStoreFactory.create( corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }), - migrations = listOf(SharedPreferencesMigration(appContext, USER_PREFERENCES_NAME)), + migrations = + listOf( + SharedPreferencesMigration(context = appContext, sharedPreferencesName = USER_PREFERENCES_NAME), + SharedPreferencesMigration( + context = appContext, + sharedPreferencesName = "ui-prefs", + keysToMigrate = setOf(KEY_APP_INTRO_COMPLETED, KEY_THEME), + ), + ), scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), produceFile = { appContext.preferencesDataStoreFile(USER_PREFERENCES_NAME) }, ) diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/ui/UiPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/ui/UiPrefs.kt index 5a2ac697a..262a5252b 100644 --- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/ui/UiPrefs.kt +++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/ui/UiPrefs.kt @@ -18,12 +18,10 @@ package org.meshtastic.core.prefs.ui import android.content.SharedPreferences -import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.edit import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import org.meshtastic.core.prefs.PrefDelegate import org.meshtastic.core.prefs.di.UiSharedPreferences import java.util.concurrent.ConcurrentHashMap @@ -31,10 +29,6 @@ import javax.inject.Inject import javax.inject.Singleton interface UiPrefs { - var theme: Int - val themeFlow: StateFlow - var appIntroCompleted: Boolean - val appIntroCompletedFlow: StateFlow var hasShownNotPairedWarning: Boolean var nodeSortOption: Int var includeUnknown: Boolean @@ -49,28 +43,15 @@ interface UiPrefs { fun setShouldProvideNodeLocation(nodeNum: Int, value: Boolean) } -const val KEY_THEME = "theme" -const val KEY_APP_INTRO_COMPLETED = "app_intro_completed" - @Singleton class UiPrefsImpl @Inject constructor(@UiSharedPreferences private val prefs: SharedPreferences) : UiPrefs { - override var theme: Int by PrefDelegate(prefs, KEY_THEME, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - private var _themeFlow = MutableStateFlow(theme) - override val themeFlow = _themeFlow.asStateFlow() - - override var appIntroCompleted: Boolean by PrefDelegate(prefs, KEY_APP_INTRO_COMPLETED, false) - private var _appIntroCompletedFlow = MutableStateFlow(appIntroCompleted) - override val appIntroCompletedFlow = _appIntroCompletedFlow.asStateFlow() - // Maps nodeNum to a flow for the for the "provide-location-nodeNum" pref private val provideNodeLocationFlows = ConcurrentHashMap>() private val sharedPreferencesListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> when (key) { - KEY_THEME -> _themeFlow.update { theme } - KEY_APP_INTRO_COMPLETED -> _appIntroCompletedFlow.update { appIntroCompleted } // Check if the changed key is one of our node location keys else -> provideNodeLocationFlows.keys.forEach { nodeNum ->