diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 59cb7ec9..1838c888 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -30,6 +30,10 @@ class BitchatApplication : Application() { com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this) } catch (_: Exception) { } + try { + com.bitchat.android.storage.StorageModule.registerKnownPanicStores(this) + } catch (_: Exception) { } + // Warm up Nostr identity to ensure npub is available for favorite notifications try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this) diff --git a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt index d4fcd1f7..45c4e317 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -2,9 +2,10 @@ package com.bitchat.android.favorites import android.content.Context import android.util.Log -import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.storage.PanicClearRegistry +import com.bitchat.android.storage.StorageDefinitions +import com.bitchat.android.storage.StorageModule import com.bitchat.android.util.toHexString -import com.google.gson.Gson import com.google.gson.reflect.TypeToken import java.util.* @@ -82,14 +83,14 @@ class FavoritesPersistenceService private constructor(private val context: Conte } } - private val stateManager = SecureIdentityStateManager(context) - private val gson = Gson() + private val storage = StorageModule.repository(context, StorageDefinitions.Favorites) private val favorites = mutableMapOf() // noiseHex -> relationship // NEW: Index by current mesh peerID (16-hex) for direct lookup when sending Nostr DMs from mesh context private val peerIdIndex = mutableMapOf() // peerID (lowercase 16-hex) -> npub private val listeners = mutableListOf() init { + PanicClearRegistry.register(StorageDefinitions.Favorites) { clearAllFavorites() } loadFavorites() loadPeerIdIndex() } @@ -232,9 +233,8 @@ class FavoritesPersistenceService private constructor(private val context: Conte fun clearAllFavorites() { favorites.clear() - saveFavorites() peerIdIndex.clear() - savePeerIdIndex() + storage.clearForPanic() Log.i(TAG, "Cleared all favorites") notifyAllCleared() } @@ -257,11 +257,9 @@ class FavoritesPersistenceService private constructor(private val context: Conte private fun loadFavorites() { try { - val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY) - if (favoritesJson != null) { - val type = object : TypeToken>() {}.type - val data: Map = gson.fromJson(favoritesJson, type) - + val type = object : TypeToken>() {}.type + val data: Map? = storage.getJson(FAVORITES_KEY, type) + if (data != null) { favorites.clear() data.forEach { (key, relationshipData) -> favorites[key] = relationshipData.toFavoriteRelationship() @@ -278,8 +276,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte val data = favorites.mapValues { (_, relationship) -> FavoriteRelationshipData.fromFavoriteRelationship(relationship) } - val favoritesJson = gson.toJson(data) - stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson) + storage.putJson(FAVORITES_KEY, data) Log.d(TAG, "Saved ${favorites.size} favorite relationships") } catch (e: Exception) { Log.e(TAG, "Failed to save favorites: ${e.message}") @@ -288,10 +285,9 @@ class FavoritesPersistenceService private constructor(private val context: Conte private fun loadPeerIdIndex() { try { - val json = stateManager.getSecureValue(PEERID_INDEX_KEY) - if (json != null) { - val type = object : TypeToken>() {}.type - val data: Map = gson.fromJson(json, type) + val type = object : TypeToken>() {}.type + val data: Map? = storage.getJson(PEERID_INDEX_KEY, type) + if (data != null) { peerIdIndex.clear() peerIdIndex.putAll(data) Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings") @@ -303,8 +299,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte private fun savePeerIdIndex() { try { - val json = gson.toJson(peerIdIndex) - stateManager.storeSecureValue(PEERID_INDEX_KEY, json) + storage.putJson(PEERID_INDEX_KEY, peerIdIndex) Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings") } catch (e: Exception) { Log.e(TAG, "Failed to save peerID index: ${e.message}") diff --git a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt index 81d83b79..30f4d589 100644 --- a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt +++ b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt @@ -5,6 +5,9 @@ import android.location.Geocoder import android.location.Location import android.location.LocationManager import android.util.Log +import com.bitchat.android.storage.PanicClearRegistry +import com.bitchat.android.storage.StorageDefinitions +import com.bitchat.android.storage.StorageModule import com.google.gson.Gson import com.google.gson.reflect.TypeToken import kotlinx.coroutines.CoroutineScope @@ -17,7 +20,6 @@ import java.util.Locale /** * Stores a user-maintained list of bookmarked geohash channels. - * - Persistence: SharedPreferences (JSON string array) * - Semantics: geohashes are normalized to lowercase base32 and de-duplicated */ class GeohashBookmarksStore private constructor(private val context: Context) { @@ -43,7 +45,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) { } private val gson = Gson() - private val prefs = context.getSharedPreferences("geohash_prefs", Context.MODE_PRIVATE) + private val storage = StorageModule.repository(context, StorageDefinitions.GeohashBookmarks) private val membership = mutableSetOf() @@ -56,7 +58,10 @@ class GeohashBookmarksStore private constructor(private val context: Context) { // For throttling / preventing duplicate geocode lookups private val resolving = mutableSetOf() - init { load() } + init { + PanicClearRegistry.register(StorageDefinitions.GeohashBookmarks) { clearAll() } + load() + } fun isBookmarked(geohash: String): Boolean = membership.contains(normalize(geohash)) @@ -95,7 +100,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) { private fun load() { try { - val arrJson = prefs.getString(STORE_KEY, null) + val arrJson = storage.getString(STORE_KEY, null) if (!arrJson.isNullOrEmpty()) { val listType = object : TypeToken>() {}.type val arr = gson.fromJson>(arrJson, listType) @@ -115,7 +120,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) { Log.e(TAG, "Failed to load bookmarks: ${e.message}") } try { - val namesJson = prefs.getString(NAMES_STORE_KEY, null) + val namesJson = storage.getString(NAMES_STORE_KEY, null) if (!namesJson.isNullOrEmpty()) { val mapType = object : TypeToken>() {}.type val dict = gson.fromJson>(namesJson, mapType) @@ -129,14 +134,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) { private fun persist() { try { val json = gson.toJson(_bookmarks.value) - prefs.edit().putString(STORE_KEY, json).apply() + storage.putString(STORE_KEY, json) } catch (_: Exception) {} } private fun persistNames() { try { val json = gson.toJson(_bookmarkNames.value) - prefs.edit().putString(NAMES_STORE_KEY, json).apply() + storage.putString(NAMES_STORE_KEY, json) } catch (_: Exception) {} } @@ -147,10 +152,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) { membership.clear() _bookmarks.value = emptyList() _bookmarkNames.value = emptyMap() - prefs.edit() - .remove(STORE_KEY) - .remove(NAMES_STORE_KEY) - .apply() + storage.clearForPanic() // Clear any in-flight resolutions to avoid repopulating resolving.clear() Log.i(TAG, "Cleared all geohash bookmarks and names") @@ -234,14 +236,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) { private fun persist(list: List) { try { val json = gson.toJson(list) - prefs.edit().putString(STORE_KEY, json).apply() + storage.putString(STORE_KEY, json) } catch (_: Exception) {} } private fun persistNames(map: Map) { try { val json = gson.toJson(map) - prefs.edit().putString(NAMES_STORE_KEY, json).apply() + storage.putString(NAMES_STORE_KEY, json) } catch (_: Exception) {} } } diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt index 7da60534..333fd951 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -381,7 +381,7 @@ class SecureIdentityStateManager(private val context: Context) { return prefs.contains(KEY_STATIC_PRIVATE_KEY) && prefs.contains(KEY_STATIC_PUBLIC_KEY) } - // MARK: - Public SharedPreferences Access (for favorites and Nostr data) + // MARK: - Public Secure Key-Value Access /** * Store a string value in secure preferences diff --git a/app/src/main/java/com/bitchat/android/net/TorPreferenceManager.kt b/app/src/main/java/com/bitchat/android/net/TorPreferenceManager.kt index 5e02a60b..402b9aac 100644 --- a/app/src/main/java/com/bitchat/android/net/TorPreferenceManager.kt +++ b/app/src/main/java/com/bitchat/android/net/TorPreferenceManager.kt @@ -1,33 +1,33 @@ package com.bitchat.android.net import android.content.Context +import com.bitchat.android.storage.StorageDefinitions +import com.bitchat.android.storage.StorageModule import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow object TorPreferenceManager { - private const val PREFS_NAME = "bitchat_settings" private const val KEY_TOR_MODE = "tor_mode" private val _modeFlow = MutableStateFlow(TorMode.ON) val modeFlow: StateFlow = _modeFlow fun init(context: Context) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val saved = prefs.getString(KEY_TOR_MODE, TorMode.ON.name) + val storage = StorageModule.repository(context, StorageDefinitions.AppSettings) + val saved = storage.getString(KEY_TOR_MODE, TorMode.ON.name) val mode = runCatching { TorMode.valueOf(saved ?: TorMode.ON.name) }.getOrDefault(TorMode.ON) _modeFlow.value = mode } fun set(context: Context, mode: TorMode) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit().putString(KEY_TOR_MODE, mode.name).apply() + val storage = StorageModule.repository(context, StorageDefinitions.AppSettings) + storage.putString(KEY_TOR_MODE, mode.name) _modeFlow.value = mode } fun get(context: Context): TorMode { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val saved = prefs.getString(KEY_TOR_MODE, TorMode.ON.name) + val storage = StorageModule.repository(context, StorageDefinitions.AppSettings) + val saved = storage.getString(KEY_TOR_MODE, TorMode.ON.name) return runCatching { TorMode.valueOf(saved ?: TorMode.ON.name) }.getOrDefault(TorMode.ON) } } - diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt index 9ead2485..b788b5ae 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt @@ -1,40 +1,61 @@ package com.bitchat.android.nostr import android.content.Context -import android.content.SharedPreferences +import com.bitchat.android.storage.PanicClearRegistry +import com.bitchat.android.storage.StorageDefinitions +import com.bitchat.android.storage.StorageMigration +import com.bitchat.android.storage.StorageModule +import com.bitchat.android.storage.StorageRepository +import com.google.gson.reflect.TypeToken import java.util.concurrent.ConcurrentHashMap -/** - * GeohashAliasRegistry - * - Global, thread-safe registry for alias->Nostr pubkey mappings (e.g., nostr_ -> pubkeyHex) - * - Persisted to SharedPreferences to survive app restarts. - */ object GeohashAliasRegistry { private val map: MutableMap = ConcurrentHashMap() private const val PREFS_NAME = "geohash_alias_registry" - private var prefs: SharedPreferences? = null + private const val ENTRIES_KEY = "entries_v1" + private var storage: StorageRepository? = null fun initialize(context: Context) { - if (prefs == null) { - prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - loadFromPrefs() + if (storage == null) { + val legacyPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + storage = StorageModule.repository( + context = context, + definition = StorageDefinitions.GeohashAliasRegistry, + migrations = listOf( + StorageMigration(toVersion = 1) { repository -> + if (!repository.contains(ENTRIES_KEY)) { + val migrated = legacyPrefs.all + .mapNotNull { (key, value) -> if (value is String) key to value else null } + .toMap() + if (migrated.isNotEmpty()) { + repository.putJson(ENTRIES_KEY, migrated) + migrated.keys.forEach { repository.remove(it) } + } + } + } + ) + ) + PanicClearRegistry.register(StorageDefinitions.GeohashAliasRegistry) { clear() } + loadFromStorage() } } - private fun loadFromPrefs() { - prefs?.let { p -> - val allEntries = p.all - for ((key, value) in allEntries) { - if (key is String && value is String) { - map[key] = value - } - } + private fun loadFromStorage() { + val repository = storage ?: return + val type = object : TypeToken>() {}.type + repository.getJson>(ENTRIES_KEY, type)?.let { entries -> + map.clear() + map.putAll(entries) } } + private fun persist() { + storage?.putJson(ENTRIES_KEY, map.toMap()) + } + fun put(alias: String, pubkeyHex: String) { map[alias] = pubkeyHex - prefs?.edit()?.putString(alias, pubkeyHex)?.apply() + persist() } fun get(alias: String): String? = map[alias] @@ -45,6 +66,6 @@ object GeohashAliasRegistry { fun clear() { map.clear() - prefs?.edit()?.clear()?.apply() + storage?.clearForPanic() } } diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashConversationRegistry.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashConversationRegistry.kt index 68c1b29e..9fb8c715 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashConversationRegistry.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashConversationRegistry.kt @@ -1,42 +1,62 @@ package com.bitchat.android.nostr import android.content.Context -import android.content.SharedPreferences +import com.bitchat.android.storage.PanicClearRegistry +import com.bitchat.android.storage.StorageDefinitions +import com.bitchat.android.storage.StorageMigration +import com.bitchat.android.storage.StorageModule +import com.bitchat.android.storage.StorageRepository +import com.google.gson.reflect.TypeToken import java.util.concurrent.ConcurrentHashMap -/** - * GeohashConversationRegistry - * - Global, thread-safe registry of conversationKey (e.g., "nostr_") -> source geohash - * - Enables routing geohash DMs from anywhere by providing the correct geohash identity - * - Persisted to SharedPreferences to survive app restarts. - */ object GeohashConversationRegistry { private val map = ConcurrentHashMap() private const val PREFS_NAME = "geohash_conversation_registry" - private var prefs: SharedPreferences? = null + private const val ENTRIES_KEY = "entries_v1" + private var storage: StorageRepository? = null fun initialize(context: Context) { - if (prefs == null) { - prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - loadFromPrefs() + if (storage == null) { + val legacyPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + storage = StorageModule.repository( + context = context, + definition = StorageDefinitions.GeohashConversationRegistry, + migrations = listOf( + StorageMigration(toVersion = 1) { repository -> + if (!repository.contains(ENTRIES_KEY)) { + val migrated = legacyPrefs.all + .mapNotNull { (key, value) -> if (value is String) key to value else null } + .toMap() + if (migrated.isNotEmpty()) { + repository.putJson(ENTRIES_KEY, migrated) + migrated.keys.forEach { repository.remove(it) } + } + } + } + ) + ) + PanicClearRegistry.register(StorageDefinitions.GeohashConversationRegistry) { clear() } + loadFromStorage() } } - private fun loadFromPrefs() { - prefs?.let { p -> - val allEntries = p.all - for ((key, value) in allEntries) { - if (key is String && value is String) { - map[key] = value - } - } + private fun loadFromStorage() { + val repository = storage ?: return + val type = object : TypeToken>() {}.type + repository.getJson>(ENTRIES_KEY, type)?.let { entries -> + map.clear() + map.putAll(entries) } } + private fun persist() { + storage?.putJson(ENTRIES_KEY, map.toMap()) + } + fun set(convKey: String, geohash: String) { if (geohash.isNotEmpty()) { map[convKey] = geohash - prefs?.edit()?.putString(convKey, geohash)?.apply() + persist() } } @@ -46,6 +66,6 @@ object GeohashConversationRegistry { fun clear() { map.clear() - prefs?.edit()?.clear()?.apply() + storage?.clearForPanic() } } diff --git a/app/src/main/java/com/bitchat/android/nostr/PoWPreferenceManager.kt b/app/src/main/java/com/bitchat/android/nostr/PoWPreferenceManager.kt index 3a3042fe..9271d3ba 100644 --- a/app/src/main/java/com/bitchat/android/nostr/PoWPreferenceManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/PoWPreferenceManager.kt @@ -1,7 +1,9 @@ package com.bitchat.android.nostr import android.content.Context -import android.content.SharedPreferences +import com.bitchat.android.storage.StorageDefinitions +import com.bitchat.android.storage.StorageModule +import com.bitchat.android.storage.StorageRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -11,7 +13,6 @@ import kotlinx.coroutines.flow.asStateFlow */ object PoWPreferenceManager { - private const val PREFS_NAME = "pow_preferences" private const val KEY_POW_ENABLED = "pow_enabled" private const val KEY_POW_DIFFICULTY = "pow_difficulty" @@ -30,7 +31,7 @@ object PoWPreferenceManager { private val _isMining = MutableStateFlow(false) val isMining: StateFlow = _isMining.asStateFlow() - private lateinit var sharedPrefs: SharedPreferences + private lateinit var storage: StorageRepository private var isInitialized = false /** @@ -40,11 +41,11 @@ object PoWPreferenceManager { fun init(context: Context) { if (isInitialized) return - sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + storage = StorageModule.repository(context, StorageDefinitions.PowPreferences) // Load current values - _powEnabled.value = sharedPrefs.getBoolean(KEY_POW_ENABLED, DEFAULT_POW_ENABLED) - _powDifficulty.value = sharedPrefs.getInt(KEY_POW_DIFFICULTY, DEFAULT_POW_DIFFICULTY) + _powEnabled.value = storage.getBoolean(KEY_POW_ENABLED, DEFAULT_POW_ENABLED) + _powDifficulty.value = storage.getInt(KEY_POW_DIFFICULTY, DEFAULT_POW_DIFFICULTY) isInitialized = true } @@ -61,8 +62,8 @@ object PoWPreferenceManager { */ fun setPowEnabled(enabled: Boolean) { _powEnabled.value = enabled - if (::sharedPrefs.isInitialized) { - sharedPrefs.edit().putBoolean(KEY_POW_ENABLED, enabled).apply() + if (::storage.isInitialized) { + storage.putBoolean(KEY_POW_ENABLED, enabled) } } @@ -79,8 +80,8 @@ object PoWPreferenceManager { fun setPowDifficulty(difficulty: Int) { val clampedDifficulty = difficulty.coerceIn(0, 32) _powDifficulty.value = clampedDifficulty - if (::sharedPrefs.isInitialized) { - sharedPrefs.edit().putInt(KEY_POW_DIFFICULTY, clampedDifficulty).apply() + if (::storage.isInitialized) { + storage.putInt(KEY_POW_DIFFICULTY, clampedDifficulty) } } diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt index 0a73e346..9486f661 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPreferenceManager.kt @@ -1,21 +1,19 @@ package com.bitchat.android.onboarding import android.content.Context +import com.bitchat.android.storage.StorageDefinitions +import com.bitchat.android.storage.StorageModule -/** - * Preference manager for background location skip choice. - */ object BackgroundLocationPreferenceManager { - private const val PREFS_NAME = "bitchat_settings" private const val KEY_BACKGROUND_LOCATION_SKIP = "background_location_skipped" fun setSkipped(context: Context, skipped: Boolean) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit().putBoolean(KEY_BACKGROUND_LOCATION_SKIP, skipped).apply() + val storage = StorageModule.repository(context, StorageDefinitions.AppSettings) + storage.putBoolean(KEY_BACKGROUND_LOCATION_SKIP, skipped) } fun isSkipped(context: Context): Boolean { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getBoolean(KEY_BACKGROUND_LOCATION_SKIP, false) + val storage = StorageModule.repository(context, StorageDefinitions.AppSettings) + return storage.getBoolean(KEY_BACKGROUND_LOCATION_SKIP, false) } } diff --git a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationPreferenceManager.kt b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationPreferenceManager.kt index c749dc43..05959dfa 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationPreferenceManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationPreferenceManager.kt @@ -1,33 +1,31 @@ package com.bitchat.android.onboarding import android.content.Context +import com.bitchat.android.storage.StorageDefinitions +import com.bitchat.android.storage.StorageModule import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -/** - * Preference manager for battery optimization skip choice - */ object BatteryOptimizationPreferenceManager { - private const val PREFS_NAME = "bitchat_settings" private const val KEY_BATTERY_SKIP = "battery_optimization_skipped" private val _skipFlow = MutableStateFlow(false) val skipFlow: StateFlow = _skipFlow fun init(context: Context) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val skipped = prefs.getBoolean(KEY_BATTERY_SKIP, false) + val storage = StorageModule.repository(context, StorageDefinitions.AppSettings) + val skipped = storage.getBoolean(KEY_BATTERY_SKIP, false) _skipFlow.value = skipped } fun setSkipped(context: Context, skipped: Boolean) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit().putBoolean(KEY_BATTERY_SKIP, skipped).apply() + val storage = StorageModule.repository(context, StorageDefinitions.AppSettings) + storage.putBoolean(KEY_BATTERY_SKIP, skipped) _skipFlow.value = skipped } fun isSkipped(context: Context): Boolean { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getBoolean(KEY_BATTERY_SKIP, false) + val storage = StorageModule.repository(context, StorageDefinitions.AppSettings) + return storage.getBoolean(KEY_BATTERY_SKIP, false) } } diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt index c32f855b..a2a0a3c7 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -8,6 +8,8 @@ import android.os.PowerManager import android.util.Log import androidx.core.content.ContextCompat import com.bitchat.android.R +import com.bitchat.android.storage.StorageDefinitions +import com.bitchat.android.storage.StorageModule /** * Centralized permission management for bitchat app @@ -17,26 +19,23 @@ class PermissionManager(private val context: Context) { companion object { private const val TAG = "PermissionManager" - private const val PREFS_NAME = "bitchat_permissions" private const val KEY_FIRST_TIME_COMPLETE = "first_time_onboarding_complete" } - private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val storage = StorageModule.repository(context, StorageDefinitions.OnboardingPermissions) /** * Check if this is the first time the user is launching the app */ fun isFirstTimeLaunch(): Boolean { - return !sharedPrefs.getBoolean(KEY_FIRST_TIME_COMPLETE, false) + return !storage.getBoolean(KEY_FIRST_TIME_COMPLETE, false) } /** * Mark the first-time onboarding as complete */ fun markOnboardingComplete() { - sharedPrefs.edit() - .putBoolean(KEY_FIRST_TIME_COMPLETE, true) - .apply() + storage.putBoolean(KEY_FIRST_TIME_COMPLETE, true) Log.d(TAG, "First-time onboarding marked as complete") } diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt index aa9e6fc3..e90fecf2 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -125,14 +125,20 @@ class MeshForegroundService : Service() { notificationManager = NotificationManagerCompat.from(this) createChannel() - // Ensure mesh service exists in holder (create if needed) + // Avoid creating or holding a mesh instance unless this service can own + // the foreground/background lifecycle. val existing = MeshServiceHolder.meshService if (existing != null) { Log.d("MeshForegroundService", "Using existing BluetoothMeshService from holder") - } else { + } else if (isForegroundMeshEligible()) { val created = MeshServiceHolder.getOrCreate(applicationContext) Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder") MeshServiceHolder.attach(created) + } else { + Log.i( + "MeshForegroundService", + "Mesh not created; foreground mesh is not eligible (background=${isBackgroundMeshEnabled()}, notification=${hasNotificationPermission()}, bluetooth=${hasBluetoothPermissions()})" + ) } } @@ -147,11 +153,10 @@ class MeshForegroundService : Service() { when (intent?.action) { ACTION_STOP -> { // Stop FGS and mesh cleanly - try { meshService?.stopServices() } catch (_: Exception) { } - try { MeshServiceHolder.clear() } catch (_: Exception) { } - try { stopForeground(true) } catch (_: Exception) { } - notificationManager.cancel(NOTIFICATION_ID) - isInForeground = false + updateJob?.cancel() + updateJob = null + ensureMeshStopped("stop requested") + clearForegroundNotification(removeNotification = true) stopSelf() return START_NOT_STICKY } @@ -177,7 +182,7 @@ class MeshForegroundService : Service() { } ACTION_UPDATE_NOTIFICATION -> { // If we became eligible and are not in foreground yet, promote once - if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { + if (isForegroundMeshEligible() && !isInForeground) { val n = buildNotification(meshService?.getActivePeerCount() ?: 0) startForegroundCompat(n) isInForeground = true @@ -188,11 +193,18 @@ class MeshForegroundService : Service() { else -> { /* ACTION_START or null */ } } + if (!isForegroundMeshEligible()) { + ensureMeshStopped("foreground mesh no longer eligible") + clearForegroundNotification(removeNotification = true) + stopSelf() + return START_NOT_STICKY + } + // Ensure mesh is running (only after permissions are granted) ensureMeshStarted() // Promote exactly once when eligible, otherwise stay background (or stop) - if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { + if (isForegroundMeshEligible() && !isInForeground) { val notification = buildNotification(meshService?.getActivePeerCount() ?: 0) startForegroundCompat(notification) isInForeground = true @@ -203,18 +215,16 @@ class MeshForegroundService : Service() { updateJob = scope.launch { while (isActive) { // Retry enabling mesh/foreground once permissions become available - ensureMeshStarted() - val eligible = MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() + val eligible = isForegroundMeshEligible() if (eligible) { + ensureMeshStarted() // Only update the notification; do not re-call startForeground() updateNotification(force = false) } else { - // If disabled or perms missing, ensure we are not in foreground and clear notif - if (isInForeground) { - try { stopForeground(false) } catch (_: Exception) { } - isInForeground = false - } - notificationManager.cancel(NOTIFICATION_ID) + ensureMeshStopped("foreground mesh eligibility lost") + clearForegroundNotification(removeNotification = true) + stopSelf() + break } delay(5000) } @@ -226,7 +236,10 @@ class MeshForegroundService : Service() { private fun ensureMeshStarted() { if (isShuttingDown) return - if (!hasBluetoothPermissions()) return + if (!isForegroundMeshEligible()) { + ensureMeshStopped("foreground mesh not eligible") + return + } try { android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") val service = MeshServiceHolder.getOrCreate(applicationContext) @@ -236,6 +249,22 @@ class MeshForegroundService : Service() { } } + private fun ensureMeshStopped(reason: String) { + val service = meshService ?: return + try { + android.util.Log.i("MeshForegroundService", "Stopping mesh service: $reason") + service.stopServices() + } catch (e: Exception) { + android.util.Log.w("MeshForegroundService", "Failed to stop mesh service: ${e.message}") + } finally { + try { + if (MeshServiceHolder.meshService === service) { + MeshServiceHolder.clear() + } + } catch (_: Exception) { } + } + } + private fun updateNotification(force: Boolean) { if (isShuttingDown) { notificationManager.cancel(NOTIFICATION_ID) @@ -243,16 +272,23 @@ class MeshForegroundService : Service() { } val count = meshService?.getActivePeerCount() ?: 0 val notification = buildNotification(count) - if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { + if (isForegroundMeshEligible()) { notificationManager.notify(NOTIFICATION_ID, notification) } else if (force) { // If disabled and forced, make sure to remove any prior foreground state - try { stopForeground(false) } catch (_: Exception) { } - notificationManager.cancel(NOTIFICATION_ID) - isInForeground = false + ensureMeshStopped("notification update while ineligible") + clearForegroundNotification(removeNotification = true) } } + private fun isForegroundMeshEligible(): Boolean { + return isBackgroundMeshEnabled() && hasAllRequiredPermissions() + } + + private fun isBackgroundMeshEnabled(): Boolean { + return MeshServicePreferences.isBackgroundEnabled(true) + } + private fun hasAllRequiredPermissions(): Boolean { // For starting FGS with connectedDevice|dataSync, we need: // - Foreground service permissions (declared in manifest) @@ -280,6 +316,16 @@ class MeshForegroundService : Service() { } else true } + private fun clearForegroundNotification(removeNotification: Boolean) { + if (isInForeground) { + try { stopForeground(removeNotification) } catch (_: Exception) { } + isInForeground = false + } + if (removeNotification) { + notificationManager.cancel(NOTIFICATION_ID) + } + } + private fun buildNotification(activePeers: Int): Notification { val openIntent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( @@ -362,13 +408,13 @@ class MeshForegroundService : Service() { override fun onDestroy() { updateJob?.cancel() updateJob = null + if (isShuttingDown || !isForegroundMeshEligible()) { + ensureMeshStopped("service destroyed") + } // Cancel the service coroutine scope to prevent leaks try { serviceJob.cancel() } catch (_: Exception) { } // Best-effort ensure we are not marked foreground - if (isInForeground) { - try { stopForeground(true) } catch (_: Exception) { } - isInForeground = false - } + clearForegroundNotification(removeNotification = true) super.onDestroy() } diff --git a/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt b/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt index 47335ee3..b2791a05 100644 --- a/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt +++ b/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt @@ -1,32 +1,35 @@ package com.bitchat.android.service import android.content.Context -import android.content.SharedPreferences +import com.bitchat.android.storage.StorageDefinitions +import com.bitchat.android.storage.StorageModule +import com.bitchat.android.storage.StorageRepository object MeshServicePreferences { - private const val PREFS_NAME = "bitchat_mesh_service_prefs" private const val KEY_AUTO_START = "auto_start_on_boot" private const val KEY_BACKGROUND_ENABLED = "background_enabled" - private lateinit var prefs: SharedPreferences + private lateinit var storage: StorageRepository fun init(context: Context) { - prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + storage = StorageModule.repository(context, StorageDefinitions.MeshServicePreferences) } + private fun ready(): Boolean = ::storage.isInitialized + fun isAutoStartEnabled(default: Boolean = true): Boolean { - return prefs.getBoolean(KEY_AUTO_START, default) + return if (ready()) storage.getBoolean(KEY_AUTO_START, default) else default } fun setAutoStartEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_AUTO_START, enabled).apply() + if (ready()) storage.putBoolean(KEY_AUTO_START, enabled) } fun isBackgroundEnabled(default: Boolean = true): Boolean { - return prefs.getBoolean(KEY_BACKGROUND_ENABLED, default) + return if (ready()) storage.getBoolean(KEY_BACKGROUND_ENABLED, default) else default } fun setBackgroundEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_BACKGROUND_ENABLED, enabled).apply() + if (ready()) storage.putBoolean(KEY_BACKGROUND_ENABLED, enabled) } } diff --git a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt index 15e1c7ca..192ae5ba 100644 --- a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt +++ b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt @@ -2,8 +2,9 @@ package com.bitchat.android.services import android.content.Context import android.util.Log -import com.bitchat.android.identity.SecureIdentityStateManager -import com.google.gson.Gson +import com.bitchat.android.storage.PanicClearRegistry +import com.bitchat.android.storage.StorageDefinitions +import com.bitchat.android.storage.StorageModule /** * Persistent store for message IDs we've already acknowledged (DELIVERED) or READ. @@ -23,13 +24,15 @@ class SeenMessageStore private constructor(private val context: Context) { } } - private val gson = Gson() - private val secure = SecureIdentityStateManager(context) + private val storage = StorageModule.repository(context, StorageDefinitions.SeenMessages) private val delivered = LinkedHashSet(MAX_IDS) private val read = LinkedHashSet(MAX_IDS) - init { load() } + init { + PanicClearRegistry.register(StorageDefinitions.SeenMessages) { clear() } + load() + } @Synchronized fun hasDelivered(id: String) = delivered.contains(id) @Synchronized fun hasRead(id: String) = read.contains(id) @@ -53,7 +56,7 @@ class SeenMessageStore private constructor(private val context: Context) { @Synchronized fun clear() { delivered.clear() read.clear() - persist() + storage.clearForPanic() } private fun trim(set: LinkedHashSet) { @@ -66,8 +69,7 @@ class SeenMessageStore private constructor(private val context: Context) { @Synchronized private fun load() { try { - val json = secure.getSecureValue(STORAGE_KEY) ?: return - val data = gson.fromJson(json, StorePayload::class.java) ?: return + val data = storage.getJson(STORAGE_KEY, StorePayload::class.java) ?: return delivered.clear(); read.clear() data.delivered.takeLast(MAX_IDS).forEach { delivered.add(it) } data.read.takeLast(MAX_IDS).forEach { read.add(it) } @@ -80,8 +82,7 @@ class SeenMessageStore private constructor(private val context: Context) { @Synchronized private fun persist() { try { val payload = StorePayload(delivered.toList(), read.toList()) - val json = gson.toJson(payload) - secure.storeSecureValue(STORAGE_KEY, json) + storage.putJson(STORAGE_KEY, payload) } catch (e: Exception) { Log.e(TAG, "Failed to persist SeenMessageStore: ${e.message}") } diff --git a/app/src/main/java/com/bitchat/android/storage/KeyValueStore.kt b/app/src/main/java/com/bitchat/android/storage/KeyValueStore.kt new file mode 100644 index 00000000..ca658360 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/storage/KeyValueStore.kt @@ -0,0 +1,17 @@ +package com.bitchat.android.storage + +interface KeyValueStore { + fun getString(key: String, defaultValue: String? = null): String? + fun putString(key: String, value: String?) + fun getBoolean(key: String, defaultValue: Boolean): Boolean + fun putBoolean(key: String, value: Boolean) + fun getInt(key: String, defaultValue: Int): Int + fun putInt(key: String, value: Int) + fun getLong(key: String, defaultValue: Long): Long + fun putLong(key: String, value: Long) + fun getStringSet(key: String, defaultValue: Set = emptySet()): Set + fun putStringSet(key: String, value: Set) + fun contains(key: String): Boolean + fun remove(key: String) + fun clear() +} diff --git a/app/src/main/java/com/bitchat/android/storage/PanicClearRegistry.kt b/app/src/main/java/com/bitchat/android/storage/PanicClearRegistry.kt new file mode 100644 index 00000000..0d3b3cec --- /dev/null +++ b/app/src/main/java/com/bitchat/android/storage/PanicClearRegistry.kt @@ -0,0 +1,60 @@ +package com.bitchat.android.storage + +data class PanicClearEntry( + val definition: StorageDefinition, + val clear: () -> Unit +) + +data class PanicClearResult( + val id: String, + val owner: String, + val success: Boolean, + val errorMessage: String? = null +) + +object PanicClearRegistry { + private val entries = linkedMapOf() + + @Synchronized + fun register(definition: StorageDefinition, clear: () -> Unit) { + if (definition.panicClearPolicy == PanicClearPolicy.CLEAR_ON_PANIC) { + entries[definition.id] = PanicClearEntry(definition, clear) + } + } + + @Synchronized + fun registerIfAbsent(definition: StorageDefinition, clear: () -> Unit) { + if (definition.panicClearPolicy == PanicClearPolicy.CLEAR_ON_PANIC && !entries.containsKey(definition.id)) { + entries[definition.id] = PanicClearEntry(definition, clear) + } + } + + @Synchronized + fun registeredEntries(): List = entries.values.toList() + + fun clearAll(): List { + val snapshot = synchronized(this) { entries.values.toList() } + return snapshot.map { entry -> + try { + entry.clear() + PanicClearResult( + id = entry.definition.id, + owner = entry.definition.owner, + success = true + ) + } catch (e: Exception) { + PanicClearResult( + id = entry.definition.id, + owner = entry.definition.owner, + success = false, + errorMessage = e.message + ) + } + } + } + + @Synchronized + fun resetForTesting() { + entries.clear() + } +} diff --git a/app/src/main/java/com/bitchat/android/storage/SecureIdentityKeyValueStore.kt b/app/src/main/java/com/bitchat/android/storage/SecureIdentityKeyValueStore.kt new file mode 100644 index 00000000..1090aff3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/storage/SecureIdentityKeyValueStore.kt @@ -0,0 +1,72 @@ +package com.bitchat.android.storage + +import android.content.Context +import com.bitchat.android.identity.SecureIdentityStateManager + +class SecureIdentityKeyValueStore( + context: Context +) : KeyValueStore { + + private val secure = SecureIdentityStateManager(context.applicationContext) + + override fun getString(key: String, defaultValue: String?): String? { + return secure.getSecureValue(key) ?: defaultValue + } + + override fun putString(key: String, value: String?) { + if (value == null) { + secure.removeSecureValue(key) + } else { + secure.storeSecureValue(key, value) + } + } + + override fun getBoolean(key: String, defaultValue: Boolean): Boolean { + return getString(key)?.toBooleanStrictOrNull() ?: defaultValue + } + + override fun putBoolean(key: String, value: Boolean) { + putString(key, value.toString()) + } + + override fun getInt(key: String, defaultValue: Int): Int { + return getString(key)?.toIntOrNull() ?: defaultValue + } + + override fun putInt(key: String, value: Int) { + putString(key, value.toString()) + } + + override fun getLong(key: String, defaultValue: Long): Long { + return getString(key)?.toLongOrNull() ?: defaultValue + } + + override fun putLong(key: String, value: Long) { + putString(key, value.toString()) + } + + override fun getStringSet(key: String, defaultValue: Set): Set { + return getString(key)?.split(STRING_SET_SEPARATOR)?.filter { it.isNotEmpty() }?.toSet() + ?: defaultValue + } + + override fun putStringSet(key: String, value: Set) { + putString(key, value.joinToString(STRING_SET_SEPARATOR)) + } + + override fun contains(key: String): Boolean { + return secure.hasSecureValue(key) + } + + override fun remove(key: String) { + secure.removeSecureValue(key) + } + + override fun clear() { + secure.clearIdentityData() + } + + private companion object { + private const val STRING_SET_SEPARATOR = "\u001F" + } +} diff --git a/app/src/main/java/com/bitchat/android/storage/SharedPreferencesKeyValueStore.kt b/app/src/main/java/com/bitchat/android/storage/SharedPreferencesKeyValueStore.kt new file mode 100644 index 00000000..16be7e62 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/storage/SharedPreferencesKeyValueStore.kt @@ -0,0 +1,61 @@ +package com.bitchat.android.storage + +import android.content.SharedPreferences +import androidx.core.content.edit + +class SharedPreferencesKeyValueStore( + private val prefs: SharedPreferences +) : KeyValueStore { + + override fun getString(key: String, defaultValue: String?): String? { + return prefs.getString(key, defaultValue) + } + + override fun putString(key: String, value: String?) { + prefs.edit { if (value == null) remove(key) else putString(key, value) } + } + + override fun getBoolean(key: String, defaultValue: Boolean): Boolean { + return prefs.getBoolean(key, defaultValue) + } + + override fun putBoolean(key: String, value: Boolean) { + prefs.edit { putBoolean(key, value) } + } + + override fun getInt(key: String, defaultValue: Int): Int { + return prefs.getInt(key, defaultValue) + } + + override fun putInt(key: String, value: Int) { + prefs.edit { putInt(key, value) } + } + + override fun getLong(key: String, defaultValue: Long): Long { + return prefs.getLong(key, defaultValue) + } + + override fun putLong(key: String, value: Long) { + prefs.edit { putLong(key, value) } + } + + override fun getStringSet(key: String, defaultValue: Set): Set { + return prefs.getStringSet(key, defaultValue)?.toSet() ?: defaultValue + } + + override fun putStringSet(key: String, value: Set) { + prefs.edit { putStringSet(key, value) } + } + + override fun contains(key: String): Boolean { + return prefs.contains(key) + } + + override fun remove(key: String) { + prefs.edit { remove(key) } + } + + override fun clear() { + prefs.edit { clear() } + } +} diff --git a/app/src/main/java/com/bitchat/android/storage/StorageDefinition.kt b/app/src/main/java/com/bitchat/android/storage/StorageDefinition.kt new file mode 100644 index 00000000..83b19c43 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/storage/StorageDefinition.kt @@ -0,0 +1,129 @@ +package com.bitchat.android.storage + +enum class StorageSecurity { + NORMAL, + SECURE +} + +enum class PanicClearPolicy { + CLEAR_ON_PANIC, + KEEP_ON_PANIC +} + +enum class StorageClearMode { + CLEAR_SCOPE, + REMOVE_OWNED_KEYS +} + +data class StorageDefinition( + val id: String, + val owner: String, + val prefsName: String, + val security: StorageSecurity, + val panicClearPolicy: PanicClearPolicy, + val clearMode: StorageClearMode = StorageClearMode.CLEAR_SCOPE, + val ownedKeys: Set = emptySet() +) + +object StorageDefinitions { + val Chat = StorageDefinition( + id = "chat", + owner = "DataManager", + prefsName = "bitchat_prefs", + security = StorageSecurity.NORMAL, + panicClearPolicy = PanicClearPolicy.CLEAR_ON_PANIC + ) + + val GeohashBookmarks = StorageDefinition( + id = "geohash_bookmarks", + owner = "GeohashBookmarksStore", + prefsName = "geohash_prefs", + security = StorageSecurity.NORMAL, + panicClearPolicy = PanicClearPolicy.CLEAR_ON_PANIC + ) + + val GeohashAliasRegistry = StorageDefinition( + id = "geohash_alias_registry", + owner = "GeohashAliasRegistry", + prefsName = "geohash_alias_registry", + security = StorageSecurity.NORMAL, + panicClearPolicy = PanicClearPolicy.CLEAR_ON_PANIC + ) + + val GeohashConversationRegistry = StorageDefinition( + id = "geohash_conversation_registry", + owner = "GeohashConversationRegistry", + prefsName = "geohash_conversation_registry", + security = StorageSecurity.NORMAL, + panicClearPolicy = PanicClearPolicy.CLEAR_ON_PANIC + ) + + val Favorites = StorageDefinition( + id = "favorites", + owner = "FavoritesPersistenceService", + prefsName = "bitchat_identity", + security = StorageSecurity.SECURE, + panicClearPolicy = PanicClearPolicy.CLEAR_ON_PANIC, + clearMode = StorageClearMode.REMOVE_OWNED_KEYS, + ownedKeys = setOf("favorite_relationships", "favorite_peerid_index") + ) + + val SeenMessages = StorageDefinition( + id = "seen_messages", + owner = "SeenMessageStore", + prefsName = "bitchat_identity", + security = StorageSecurity.SECURE, + panicClearPolicy = PanicClearPolicy.CLEAR_ON_PANIC, + clearMode = StorageClearMode.REMOVE_OWNED_KEYS, + ownedKeys = setOf("seen_message_store_v1") + ) + + val MeshServicePreferences = StorageDefinition( + id = "mesh_service_preferences", + owner = "MeshServicePreferences", + prefsName = "bitchat_mesh_service_prefs", + security = StorageSecurity.NORMAL, + panicClearPolicy = PanicClearPolicy.KEEP_ON_PANIC + ) + + val DebugPreferences = StorageDefinition( + id = "debug_preferences", + owner = "DebugPreferenceManager", + prefsName = "bitchat_debug_settings", + security = StorageSecurity.NORMAL, + panicClearPolicy = PanicClearPolicy.KEEP_ON_PANIC + ) + + val PowPreferences = StorageDefinition( + id = "pow_preferences", + owner = "PoWPreferenceManager", + prefsName = "pow_preferences", + security = StorageSecurity.NORMAL, + panicClearPolicy = PanicClearPolicy.KEEP_ON_PANIC + ) + + val AppSettings = StorageDefinition( + id = "app_settings", + owner = "TorPreferenceManager, ThemePreferenceManager, and onboarding preference managers", + prefsName = "bitchat_settings", + security = StorageSecurity.NORMAL, + panicClearPolicy = PanicClearPolicy.KEEP_ON_PANIC + ) + + val OnboardingPermissions = StorageDefinition( + id = "onboarding_permissions", + owner = "PermissionManager", + prefsName = "bitchat_permissions", + security = StorageSecurity.NORMAL, + panicClearPolicy = PanicClearPolicy.KEEP_ON_PANIC + ) + + val panicClearDefinitions = listOf( + Chat, + GeohashBookmarks, + GeohashAliasRegistry, + GeohashConversationRegistry, + Favorites, + SeenMessages + ) +} diff --git a/app/src/main/java/com/bitchat/android/storage/StorageModule.kt b/app/src/main/java/com/bitchat/android/storage/StorageModule.kt new file mode 100644 index 00000000..bd817661 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/storage/StorageModule.kt @@ -0,0 +1,33 @@ +package com.bitchat.android.storage + +import android.content.Context +import com.google.gson.Gson + +object StorageModule { + private val gson = Gson() + + fun repository( + context: Context, + definition: StorageDefinition, + migrations: List = emptyList() + ): StorageRepository { + val appContext = context.applicationContext + val store = when (definition.security) { + StorageSecurity.NORMAL -> SharedPreferencesKeyValueStore( + appContext.getSharedPreferences(definition.prefsName, Context.MODE_PRIVATE) + ) + StorageSecurity.SECURE -> SecureIdentityKeyValueStore(appContext) + } + + val repository = StorageRepository(definition, store, gson) + repository.runMigrations(migrations) + PanicClearRegistry.registerIfAbsent(definition) { repository.clearForPanic() } + return repository + } + + fun registerKnownPanicStores(context: Context) { + StorageDefinitions.panicClearDefinitions.forEach { definition -> + repository(context, definition) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/storage/StorageRepository.kt b/app/src/main/java/com/bitchat/android/storage/StorageRepository.kt new file mode 100644 index 00000000..7b029427 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/storage/StorageRepository.kt @@ -0,0 +1,74 @@ +package com.bitchat.android.storage + +import com.google.gson.Gson +import java.lang.reflect.Type + +data class StorageMigration( + val toVersion: Int, + val migrate: (StorageRepository) -> Unit +) + +class StorageRepository( + val definition: StorageDefinition, + private val store: KeyValueStore, + private val gson: Gson = Gson() +) { + + fun getString(key: String, defaultValue: String? = null): String? = store.getString(key, defaultValue) + fun putString(key: String, value: String?) = store.putString(key, value) + fun getBoolean(key: String, defaultValue: Boolean): Boolean = store.getBoolean(key, defaultValue) + fun putBoolean(key: String, value: Boolean) = store.putBoolean(key, value) + fun getInt(key: String, defaultValue: Int): Int = store.getInt(key, defaultValue) + fun putInt(key: String, value: Int) = store.putInt(key, value) + fun getLong(key: String, defaultValue: Long): Long = store.getLong(key, defaultValue) + fun putLong(key: String, value: Long) = store.putLong(key, value) + fun getStringSet(key: String, defaultValue: Set = emptySet()): Set { + return store.getStringSet(key, defaultValue) + } + fun putStringSet(key: String, value: Set) = store.putStringSet(key, value) + fun remove(key: String) = store.remove(key) + fun contains(key: String): Boolean = store.contains(key) + + fun getJson(key: String, type: Type): T? { + val json = store.getString(key, null) ?: return null + return runCatching { gson.fromJson(json, type) }.getOrNull() + } + + fun getJson(key: String, clazz: Class): T? { + val json = store.getString(key, null) ?: return null + return runCatching { gson.fromJson(json, clazz) }.getOrNull() + } + + fun putJson(key: String, value: Any?) { + if (value == null) { + store.remove(key) + } else { + store.putString(key, gson.toJson(value)) + } + } + + fun runMigrations(migrations: List) { + var currentVersion = getInt(VERSION_KEY, 0) + migrations + .sortedBy { it.toVersion } + .filter { it.toVersion > currentVersion } + .forEach { migration -> + migration.migrate(this) + currentVersion = migration.toVersion + putInt(VERSION_KEY, currentVersion) + } + } + + fun clearForPanic() { + if (definition.panicClearPolicy != PanicClearPolicy.CLEAR_ON_PANIC) return + + when (definition.clearMode) { + StorageClearMode.CLEAR_SCOPE -> store.clear() + StorageClearMode.REMOVE_OWNED_KEYS -> definition.ownedKeys.forEach { store.remove(it) } + } + } + + companion object { + private const val VERSION_KEY = "__storage_version" + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index fc47828d..ffe2fb9a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -5,7 +5,6 @@ import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import com.bitchat.android.favorites.FavoritesPersistenceService import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -21,13 +20,13 @@ import com.bitchat.android.protocol.BitchatPacket import kotlinx.coroutines.launch import com.bitchat.android.util.NotificationIntervalManager import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import java.util.Date import kotlin.random.Random import com.bitchat.android.services.VerificationService import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.noise.NoiseSession -import com.bitchat.android.nostr.GeohashAliasRegistry +import com.bitchat.android.storage.PanicClearRegistry +import com.bitchat.android.storage.StorageModule import com.bitchat.android.util.dataFromHexString import com.bitchat.android.util.hexEncodedString @@ -910,12 +909,18 @@ class ChatViewModel( messageManager.clearAllMessages() channelManager.clearAllChannels() privateChatManager.clearAllPrivateChats() - dataManager.clearAllData() - - // Clear seen message store try { - com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() - } catch (_: Exception) { } + StorageModule.registerKnownPanicStores(getApplication()) + } catch (e: Exception) { + Log.e(TAG, "Failed to register all panic stores: ${e.message}") + } + PanicClearRegistry.clearAll().forEach { result -> + if (result.success) { + Log.d(TAG, "✅ Panic-cleared ${result.owner} (${result.id})") + } else { + Log.e(TAG, "❌ Panic clear failed for ${result.owner} (${result.id}): ${result.errorMessage}") + } + } // Clear all mesh service data clearAllMeshServiceData() @@ -929,14 +934,8 @@ class ChatViewModel( // Clear all media files com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication()) - // Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch + // Reset Nostr/geohash runtime state and reinitialize from scratch try { - // Clear geohash bookmarks too (panic should remove everything) - try { - val store = com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication()) - store.clearAll() - } catch (_: Exception) { } - geohashViewModel.panicReset() } catch (e: Exception) { Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") @@ -1006,20 +1005,10 @@ class ChatViewModel( try { val identityManager = SecureIdentityStateManager(getApplication()) identityManager.clearIdentityData() - // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) - try { - identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index") - } catch (_: Exception) { } - Log.d(TAG, "✅ Cleared secure identity state and secure favorites store") + Log.d(TAG, "✅ Cleared secure identity state") } catch (e: Exception) { Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}") } - - // Clear FavoritesPersistenceService persistent relationships - try { - FavoritesPersistenceService.shared.clearAllFavorites() - Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") - } catch (_: Exception) { } Log.d(TAG, "✅ Cleared all cryptographic data") } catch (e: Exception) { diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index b338c864..9969429e 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -1,8 +1,10 @@ package com.bitchat.android.ui import android.content.Context -import android.content.SharedPreferences import android.util.Log +import com.bitchat.android.storage.PanicClearRegistry +import com.bitchat.android.storage.StorageDefinitions +import com.bitchat.android.storage.StorageModule import com.google.gson.Gson import kotlin.random.Random @@ -15,7 +17,7 @@ class DataManager(private val context: Context) { private const val TAG = "DataManager" } - private val prefs: SharedPreferences = context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE) + private val storage = StorageModule.repository(context, StorageDefinitions.Chat) private val gson = Gson() // Channel-related maps that need to persist state @@ -28,11 +30,15 @@ class DataManager(private val context: Context) { val favoritePeers: Set get() = _favoritePeers val blockedUsers: Set get() = _blockedUsers val channelMembers: Map> get() = _channelMembers + + init { + PanicClearRegistry.register(StorageDefinitions.Chat) { clearAllData() } + } // MARK: - Nickname Management fun loadNickname(): String { - val savedNickname = prefs.getString("nickname", null) + val savedNickname = storage.getString("nickname", null) return if (savedNickname != null) { savedNickname } else { @@ -43,47 +49,47 @@ class DataManager(private val context: Context) { } fun saveNickname(nickname: String) { - prefs.edit().putString("nickname", nickname).apply() + storage.putString("nickname", nickname) } // MARK: - Geohash Channel Persistence fun loadLastGeohashChannel(): String? { - return prefs.getString("last_geohash_channel", null) + return storage.getString("last_geohash_channel", null) } fun saveLastGeohashChannel(channelData: String) { - prefs.edit().putString("last_geohash_channel", channelData).apply() + storage.putString("last_geohash_channel", channelData) Log.d(TAG, "Saved last geohash channel: $channelData") } fun clearLastGeohashChannel() { - prefs.edit().remove("last_geohash_channel").apply() + storage.remove("last_geohash_channel") Log.d(TAG, "Cleared last geohash channel") } // MARK: - Location Services State fun saveLocationServicesEnabled(enabled: Boolean) { - prefs.edit().putBoolean("location_services_enabled", enabled).apply() + storage.putBoolean("location_services_enabled", enabled) Log.d(TAG, "Saved location services enabled state: $enabled") } fun isLocationServicesEnabled(): Boolean { - return prefs.getBoolean("location_services_enabled", true) // Default to enabled + return storage.getBoolean("location_services_enabled", true) // Default to enabled } // MARK: - Channel Data Management fun loadChannelData(): Pair, Set> { // Load joined channels - val savedChannels = prefs.getStringSet("joined_channels", emptySet()) ?: emptySet() + val savedChannels = storage.getStringSet("joined_channels", emptySet()) // Load password protected channels - val savedProtectedChannels = prefs.getStringSet("password_protected_channels", emptySet()) ?: emptySet() + val savedProtectedChannels = storage.getStringSet("password_protected_channels", emptySet()) // Load channel creators - val creatorsJson = prefs.getString("channel_creators", "{}") + val creatorsJson = storage.getString("channel_creators", "{}") try { val creatorsMap = gson.fromJson(creatorsJson, Map::class.java) as? Map creatorsMap?.let { _channelCreators.putAll(it) } @@ -102,12 +108,9 @@ class DataManager(private val context: Context) { } fun saveChannelData(joinedChannels: Set, passwordProtectedChannels: Set) { - prefs.edit().apply { - putStringSet("joined_channels", joinedChannels) - putStringSet("password_protected_channels", passwordProtectedChannels) - putString("channel_creators", gson.toJson(_channelCreators)) - apply() - } + storage.putStringSet("joined_channels", joinedChannels) + storage.putStringSet("password_protected_channels", passwordProtectedChannels) + storage.putString("channel_creators", gson.toJson(_channelCreators)) } fun addChannelCreator(channel: String, creatorID: String) { @@ -156,13 +159,13 @@ class DataManager(private val context: Context) { // MARK: - Favorites Management fun loadFavorites() { - val savedFavorites = prefs.getStringSet("favorites", emptySet()) ?: emptySet() + val savedFavorites = storage.getStringSet("favorites", emptySet()) _favoritePeers.addAll(savedFavorites) Log.d(TAG, "Loaded ${savedFavorites.size} favorite users from storage: $savedFavorites") } fun saveFavorites() { - prefs.edit().putStringSet("favorites", _favoritePeers).apply() + storage.putStringSet("favorites", _favoritePeers) Log.d(TAG, "Saved ${_favoritePeers.size} favorite users to storage: $_favoritePeers") } @@ -198,12 +201,12 @@ class DataManager(private val context: Context) { // MARK: - Blocked Users Management fun loadBlockedUsers() { - val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet() + val savedBlockedUsers = storage.getStringSet("blocked_users", emptySet()) _blockedUsers.addAll(savedBlockedUsers) } fun saveBlockedUsers() { - prefs.edit().putStringSet("blocked_users", _blockedUsers).apply() + storage.putStringSet("blocked_users", _blockedUsers) } fun addBlockedUser(fingerprint: String) { @@ -226,12 +229,12 @@ class DataManager(private val context: Context) { val geohashBlockedUsers: Set get() = _geohashBlockedUsers.toSet() fun loadGeohashBlockedUsers() { - val savedGeohashBlockedUsers = prefs.getStringSet("geohash_blocked_users", emptySet()) ?: emptySet() + val savedGeohashBlockedUsers = storage.getStringSet("geohash_blocked_users", emptySet()) _geohashBlockedUsers.addAll(savedGeohashBlockedUsers) } fun saveGeohashBlockedUsers() { - prefs.edit().putStringSet("geohash_blocked_users", _geohashBlockedUsers).apply() + storage.putStringSet("geohash_blocked_users", _geohashBlockedUsers) } fun addGeohashBlockedUser(pubkeyHex: String) { @@ -256,6 +259,6 @@ class DataManager(private val context: Context) { _blockedUsers.clear() _geohashBlockedUsers.clear() _channelMembers.clear() - prefs.edit().clear().apply() + storage.clearForPanic() } } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 09b32d4f..2ce9d386 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -160,8 +160,6 @@ class GeohashViewModel( fun panicReset() { repo.clearAll() - GeohashAliasRegistry.clear() - GeohashConversationRegistry.clear() subscriptionManager.disconnect() currentGeohashSubId = null currentDmSubId = null diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt index 04ad48a2..9723f63e 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugPreferenceManager.kt @@ -1,14 +1,11 @@ package com.bitchat.android.ui.debug import android.content.Context -import android.content.SharedPreferences +import com.bitchat.android.storage.StorageDefinitions +import com.bitchat.android.storage.StorageModule +import com.bitchat.android.storage.StorageRepository -/** - * SharedPreferences-backed persistence for debug settings. - * Keeps the DebugSettingsManager stateless with regard to Android Context. - */ object DebugPreferenceManager { - private const val PREFS_NAME = "bitchat_debug_settings" private const val KEY_VERBOSE = "verbose_logging" private const val KEY_GATT_SERVER = "gatt_server_enabled" private const val KEY_GATT_CLIENT = "gatt_client_enabled" @@ -20,87 +17,82 @@ object DebugPreferenceManager { // GCS keys (no migration/back-compat) private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes" private const val KEY_GCS_FPR = "gcs_filter_fpr_percent" - // Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled - private lateinit var prefs: SharedPreferences + private lateinit var storage: StorageRepository fun init(context: Context) { - prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + storage = StorageModule.repository(context, StorageDefinitions.DebugPreferences) } - private fun ready(): Boolean = ::prefs.isInitialized + private fun ready(): Boolean = ::storage.isInitialized fun getVerboseLogging(default: Boolean = false): Boolean = - if (ready()) prefs.getBoolean(KEY_VERBOSE, default) else default + if (ready()) storage.getBoolean(KEY_VERBOSE, default) else default fun setVerboseLogging(value: Boolean) { - if (ready()) prefs.edit().putBoolean(KEY_VERBOSE, value).apply() + if (ready()) storage.putBoolean(KEY_VERBOSE, value) } fun getGattServerEnabled(default: Boolean = true): Boolean = - if (ready()) prefs.getBoolean(KEY_GATT_SERVER, default) else default + if (ready()) storage.getBoolean(KEY_GATT_SERVER, default) else default fun setGattServerEnabled(value: Boolean) { - if (ready()) prefs.edit().putBoolean(KEY_GATT_SERVER, value).apply() + if (ready()) storage.putBoolean(KEY_GATT_SERVER, value) } fun getGattClientEnabled(default: Boolean = true): Boolean = - if (ready()) prefs.getBoolean(KEY_GATT_CLIENT, default) else default + if (ready()) storage.getBoolean(KEY_GATT_CLIENT, default) else default fun setGattClientEnabled(value: Boolean) { - if (ready()) prefs.edit().putBoolean(KEY_GATT_CLIENT, value).apply() + if (ready()) storage.putBoolean(KEY_GATT_CLIENT, value) } fun getPacketRelayEnabled(default: Boolean = true): Boolean = - if (ready()) prefs.getBoolean(KEY_PACKET_RELAY, default) else default + if (ready()) storage.getBoolean(KEY_PACKET_RELAY, default) else default fun setPacketRelayEnabled(value: Boolean) { - if (ready()) prefs.edit().putBoolean(KEY_PACKET_RELAY, value).apply() + if (ready()) storage.putBoolean(KEY_PACKET_RELAY, value) } - // Optional connection limits (0 or missing => use defaults) fun getMaxConnectionsOverall(default: Int = 8): Int = - if (ready()) prefs.getInt(KEY_MAX_CONN_OVERALL, default) else default + if (ready()) storage.getInt(KEY_MAX_CONN_OVERALL, default) else default fun setMaxConnectionsOverall(value: Int) { - if (ready()) prefs.edit().putInt(KEY_MAX_CONN_OVERALL, value).apply() + if (ready()) storage.putInt(KEY_MAX_CONN_OVERALL, value) } fun getMaxConnectionsServer(default: Int = 8): Int = - if (ready()) prefs.getInt(KEY_MAX_CONN_SERVER, default) else default + if (ready()) storage.getInt(KEY_MAX_CONN_SERVER, default) else default fun setMaxConnectionsServer(value: Int) { - if (ready()) prefs.edit().putInt(KEY_MAX_CONN_SERVER, value).apply() + if (ready()) storage.putInt(KEY_MAX_CONN_SERVER, value) } fun getMaxConnectionsClient(default: Int = 8): Int = - if (ready()) prefs.getInt(KEY_MAX_CONN_CLIENT, default) else default + if (ready()) storage.getInt(KEY_MAX_CONN_CLIENT, default) else default fun setMaxConnectionsClient(value: Int) { - if (ready()) prefs.edit().putInt(KEY_MAX_CONN_CLIENT, value).apply() + if (ready()) storage.putInt(KEY_MAX_CONN_CLIENT, value) } - // Sync/GCS settings fun getSeenPacketCapacity(default: Int = 500): Int = - if (ready()) prefs.getInt(KEY_SEEN_PACKET_CAP, default) else default + if (ready()) storage.getInt(KEY_SEEN_PACKET_CAP, default) else default fun setSeenPacketCapacity(value: Int) { - if (ready()) prefs.edit().putInt(KEY_SEEN_PACKET_CAP, value).apply() + if (ready()) storage.putInt(KEY_SEEN_PACKET_CAP, value) } fun getGcsMaxFilterBytes(default: Int = 400): Int = - if (ready()) prefs.getInt(KEY_GCS_MAX_BYTES, default) else default + if (ready()) storage.getInt(KEY_GCS_MAX_BYTES, default) else default fun setGcsMaxFilterBytes(value: Int) { - if (ready()) prefs.edit().putInt(KEY_GCS_MAX_BYTES, value).apply() + if (ready()) storage.putInt(KEY_GCS_MAX_BYTES, value) } fun getGcsFprPercent(default: Double = 1.0): Double = - if (ready()) java.lang.Double.longBitsToDouble(prefs.getLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(default))) else default + if (ready()) java.lang.Double.longBitsToDouble(storage.getLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(default))) else default fun setGcsFprPercent(value: Double) { - if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply() + if (ready()) storage.putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)) } - - // No longer storing persistent notification in debug prefs. } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ThemePreference.kt b/app/src/main/java/com/bitchat/android/ui/theme/ThemePreference.kt index 7a040d9c..ad416323 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/ThemePreference.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/ThemePreference.kt @@ -1,6 +1,8 @@ package com.bitchat.android.ui.theme import android.content.Context +import com.bitchat.android.storage.StorageDefinitions +import com.bitchat.android.storage.StorageModule import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -17,26 +19,21 @@ enum class ThemePreference { val isDark : Boolean get() = this == Dark } -/** - * Simple SharedPreferences-backed manager for theme preference with a StateFlow. - * Avoids adding DataStore dependency for now. - */ object ThemePreferenceManager { - private const val PREFS_NAME = "bitchat_settings" private const val KEY_THEME = "theme_preference" private val _themeFlow = MutableStateFlow(ThemePreference.System) val themeFlow: StateFlow = _themeFlow fun init(context: Context) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val saved = prefs.getString(KEY_THEME, ThemePreference.System.name) + val storage = StorageModule.repository(context, StorageDefinitions.AppSettings) + val saved = storage.getString(KEY_THEME, ThemePreference.System.name) _themeFlow.value = runCatching { ThemePreference.valueOf(saved!!) }.getOrDefault(ThemePreference.System) } fun set(context: Context, preference: ThemePreference) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit().putString(KEY_THEME, preference.name).apply() + val storage = StorageModule.repository(context, StorageDefinitions.AppSettings) + storage.putString(KEY_THEME, preference.name) _themeFlow.value = preference } } diff --git a/app/src/test/kotlin/com/bitchat/android/storage/StorageRepositoryTest.kt b/app/src/test/kotlin/com/bitchat/android/storage/StorageRepositoryTest.kt new file mode 100644 index 00000000..370f703b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/storage/StorageRepositoryTest.kt @@ -0,0 +1,192 @@ +package com.bitchat.android.storage + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class StorageRepositoryTest { + + @After + fun tearDown() { + PanicClearRegistry.resetForTesting() + } + + @Test + fun `runs migrations once in version order`() { + val store = FakeKeyValueStore() + val repository = StorageRepository(testDefinition(), store) + val applied = mutableListOf() + + val migrations = listOf( + StorageMigration(toVersion = 2) { + applied += 2 + it.putString("v2", "done") + }, + StorageMigration(toVersion = 1) { + applied += 1 + it.putString("v1", "done") + } + ) + + repository.runMigrations(migrations) + repository.runMigrations(migrations) + + assertEquals(listOf(1, 2), applied) + assertEquals("done", repository.getString("v1")) + assertEquals("done", repository.getString("v2")) + assertEquals(2, repository.getInt("__storage_version", 0)) + } + + @Test + fun `panic clear removes only owned keys when definition scopes keys`() { + val definition = testDefinition( + clearMode = StorageClearMode.REMOVE_OWNED_KEYS, + ownedKeys = setOf("owned_a", "owned_b") + ) + val repository = StorageRepository(definition, FakeKeyValueStore()) + + repository.putString("owned_a", "a") + repository.putString("owned_b", "b") + repository.putString("unowned", "keep") + + repository.clearForPanic() + + assertNull(repository.getString("owned_a")) + assertNull(repository.getString("owned_b")) + assertEquals("keep", repository.getString("unowned")) + } + + @Test + fun `panic clear clears full scope for normal panic stores`() { + val repository = StorageRepository(testDefinition(), FakeKeyValueStore()) + repository.putString("one", "1") + repository.putBoolean("two", true) + + repository.clearForPanic() + + assertFalse(repository.contains("one")) + assertFalse(repository.contains("two")) + } + + @Test + fun `panic clear does nothing for stores kept on panic`() { + val repository = StorageRepository( + testDefinition(panicClearPolicy = PanicClearPolicy.KEEP_ON_PANIC), + FakeKeyValueStore() + ) + repository.putString("setting", "keep") + + repository.clearForPanic() + + assertEquals("keep", repository.getString("setting")) + } + + @Test + fun `registry reports successes and failures without stopping clear`() { + var successCalled = false + PanicClearRegistry.register(testDefinition(id = "success", owner = "SuccessStore")) { + successCalled = true + } + PanicClearRegistry.register(testDefinition(id = "failure", owner = "FailureStore")) { + error("boom") + } + + val results = PanicClearRegistry.clearAll() + + assertTrue(successCalled) + assertEquals(2, results.size) + assertTrue(results.first { it.id == "success" }.success) + val failure = results.first { it.id == "failure" } + assertFalse(failure.success) + assertEquals("boom", failure.errorMessage) + } + + @Test + fun `register if absent preserves richer manager clear handler`() { + val definition = testDefinition(id = "store") + val calls = mutableListOf() + + PanicClearRegistry.register(definition) { calls += "manager" } + PanicClearRegistry.registerIfAbsent(definition) { calls += "default" } + + PanicClearRegistry.clearAll() + + assertEquals(listOf("manager"), calls) + } + + private fun testDefinition( + id: String = "test_store", + owner: String = "TestStore", + panicClearPolicy: PanicClearPolicy = PanicClearPolicy.CLEAR_ON_PANIC, + clearMode: StorageClearMode = StorageClearMode.CLEAR_SCOPE, + ownedKeys: Set = emptySet() + ): StorageDefinition { + return StorageDefinition( + id = id, + owner = owner, + prefsName = "${id}_prefs", + security = StorageSecurity.NORMAL, + panicClearPolicy = panicClearPolicy, + clearMode = clearMode, + ownedKeys = ownedKeys + ) + } + + private class FakeKeyValueStore : KeyValueStore { + private val values = mutableMapOf() + + override fun getString(key: String, defaultValue: String?): String? { + return values[key] as? String ?: defaultValue + } + + override fun putString(key: String, value: String?) { + if (value == null) values.remove(key) else values[key] = value + } + + override fun getBoolean(key: String, defaultValue: Boolean): Boolean { + return values[key] as? Boolean ?: defaultValue + } + + override fun putBoolean(key: String, value: Boolean) { + values[key] = value + } + + override fun getInt(key: String, defaultValue: Int): Int { + return values[key] as? Int ?: defaultValue + } + + override fun putInt(key: String, value: Int) { + values[key] = value + } + + override fun getLong(key: String, defaultValue: Long): Long { + return values[key] as? Long ?: defaultValue + } + + override fun putLong(key: String, value: Long) { + values[key] = value + } + + override fun getStringSet(key: String, defaultValue: Set): Set { + @Suppress("UNCHECKED_CAST") + return (values[key] as? Set)?.toSet() ?: defaultValue + } + + override fun putStringSet(key: String, value: Set) { + values[key] = value.toSet() + } + + override fun contains(key: String): Boolean = values.containsKey(key) + + override fun remove(key: String) { + values.remove(key) + } + + override fun clear() { + values.clear() + } + } +}