This commit is contained in:
CC
2026-06-15 14:18:35 +02:00
parent fc38a8c6b6
commit b333434fd9
27 changed files with 961 additions and 256 deletions
@@ -30,6 +30,10 @@ class BitchatApplication : Application() {
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this) com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this)
} catch (_: Exception) { } } catch (_: Exception) { }
try {
com.bitchat.android.storage.StorageModule.registerKnownPanicStores(this)
} catch (_: Exception) { }
// Warm up Nostr identity to ensure npub is available for favorite notifications // Warm up Nostr identity to ensure npub is available for favorite notifications
try { try {
com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this) com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this)
@@ -2,9 +2,10 @@ package com.bitchat.android.favorites
import android.content.Context import android.content.Context
import android.util.Log 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.bitchat.android.util.toHexString
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import java.util.* import java.util.*
@@ -82,14 +83,14 @@ class FavoritesPersistenceService private constructor(private val context: Conte
} }
} }
private val stateManager = SecureIdentityStateManager(context) private val storage = StorageModule.repository(context, StorageDefinitions.Favorites)
private val gson = Gson()
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseHex -> relationship private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseHex -> relationship
// NEW: Index by current mesh peerID (16-hex) for direct lookup when sending Nostr DMs from mesh context // NEW: Index by current mesh peerID (16-hex) for direct lookup when sending Nostr DMs from mesh context
private val peerIdIndex = mutableMapOf<String, String>() // peerID (lowercase 16-hex) -> npub private val peerIdIndex = mutableMapOf<String, String>() // peerID (lowercase 16-hex) -> npub
private val listeners = mutableListOf<FavoritesChangeListener>() private val listeners = mutableListOf<FavoritesChangeListener>()
init { init {
PanicClearRegistry.register(StorageDefinitions.Favorites) { clearAllFavorites() }
loadFavorites() loadFavorites()
loadPeerIdIndex() loadPeerIdIndex()
} }
@@ -232,9 +233,8 @@ class FavoritesPersistenceService private constructor(private val context: Conte
fun clearAllFavorites() { fun clearAllFavorites() {
favorites.clear() favorites.clear()
saveFavorites()
peerIdIndex.clear() peerIdIndex.clear()
savePeerIdIndex() storage.clearForPanic()
Log.i(TAG, "Cleared all favorites") Log.i(TAG, "Cleared all favorites")
notifyAllCleared() notifyAllCleared()
} }
@@ -257,11 +257,9 @@ class FavoritesPersistenceService private constructor(private val context: Conte
private fun loadFavorites() { private fun loadFavorites() {
try { try {
val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY) val type = object : TypeToken<Map<String, FavoriteRelationshipData>>() {}.type
if (favoritesJson != null) { val data: Map<String, FavoriteRelationshipData>? = storage.getJson(FAVORITES_KEY, type)
val type = object : TypeToken<Map<String, FavoriteRelationshipData>>() {}.type if (data != null) {
val data: Map<String, FavoriteRelationshipData> = gson.fromJson(favoritesJson, type)
favorites.clear() favorites.clear()
data.forEach { (key, relationshipData) -> data.forEach { (key, relationshipData) ->
favorites[key] = relationshipData.toFavoriteRelationship() favorites[key] = relationshipData.toFavoriteRelationship()
@@ -278,8 +276,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
val data = favorites.mapValues { (_, relationship) -> val data = favorites.mapValues { (_, relationship) ->
FavoriteRelationshipData.fromFavoriteRelationship(relationship) FavoriteRelationshipData.fromFavoriteRelationship(relationship)
} }
val favoritesJson = gson.toJson(data) storage.putJson(FAVORITES_KEY, data)
stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson)
Log.d(TAG, "Saved ${favorites.size} favorite relationships") Log.d(TAG, "Saved ${favorites.size} favorite relationships")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to save favorites: ${e.message}") Log.e(TAG, "Failed to save favorites: ${e.message}")
@@ -288,10 +285,9 @@ class FavoritesPersistenceService private constructor(private val context: Conte
private fun loadPeerIdIndex() { private fun loadPeerIdIndex() {
try { try {
val json = stateManager.getSecureValue(PEERID_INDEX_KEY) val type = object : TypeToken<Map<String, String>>() {}.type
if (json != null) { val data: Map<String, String>? = storage.getJson(PEERID_INDEX_KEY, type)
val type = object : TypeToken<Map<String, String>>() {}.type if (data != null) {
val data: Map<String, String> = gson.fromJson(json, type)
peerIdIndex.clear() peerIdIndex.clear()
peerIdIndex.putAll(data) peerIdIndex.putAll(data)
Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings") Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings")
@@ -303,8 +299,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
private fun savePeerIdIndex() { private fun savePeerIdIndex() {
try { try {
val json = gson.toJson(peerIdIndex) storage.putJson(PEERID_INDEX_KEY, peerIdIndex)
stateManager.storeSecureValue(PEERID_INDEX_KEY, json)
Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings") Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to save peerID index: ${e.message}") Log.e(TAG, "Failed to save peerID index: ${e.message}")
@@ -5,6 +5,9 @@ import android.location.Geocoder
import android.location.Location import android.location.Location
import android.location.LocationManager import android.location.LocationManager
import android.util.Log 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.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -17,7 +20,6 @@ import java.util.Locale
/** /**
* Stores a user-maintained list of bookmarked geohash channels. * Stores a user-maintained list of bookmarked geohash channels.
* - Persistence: SharedPreferences (JSON string array)
* - Semantics: geohashes are normalized to lowercase base32 and de-duplicated * - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
*/ */
class GeohashBookmarksStore private constructor(private val context: Context) { 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 gson = Gson()
private val prefs = context.getSharedPreferences("geohash_prefs", Context.MODE_PRIVATE) private val storage = StorageModule.repository(context, StorageDefinitions.GeohashBookmarks)
private val membership = mutableSetOf<String>() private val membership = mutableSetOf<String>()
@@ -56,7 +58,10 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
// For throttling / preventing duplicate geocode lookups // For throttling / preventing duplicate geocode lookups
private val resolving = mutableSetOf<String>() private val resolving = mutableSetOf<String>()
init { load() } init {
PanicClearRegistry.register(StorageDefinitions.GeohashBookmarks) { clearAll() }
load()
}
fun isBookmarked(geohash: String): Boolean = membership.contains(normalize(geohash)) fun isBookmarked(geohash: String): Boolean = membership.contains(normalize(geohash))
@@ -95,7 +100,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
private fun load() { private fun load() {
try { try {
val arrJson = prefs.getString(STORE_KEY, null) val arrJson = storage.getString(STORE_KEY, null)
if (!arrJson.isNullOrEmpty()) { if (!arrJson.isNullOrEmpty()) {
val listType = object : TypeToken<List<String>>() {}.type val listType = object : TypeToken<List<String>>() {}.type
val arr = gson.fromJson<List<String>>(arrJson, listType) val arr = gson.fromJson<List<String>>(arrJson, listType)
@@ -115,7 +120,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
Log.e(TAG, "Failed to load bookmarks: ${e.message}") Log.e(TAG, "Failed to load bookmarks: ${e.message}")
} }
try { try {
val namesJson = prefs.getString(NAMES_STORE_KEY, null) val namesJson = storage.getString(NAMES_STORE_KEY, null)
if (!namesJson.isNullOrEmpty()) { if (!namesJson.isNullOrEmpty()) {
val mapType = object : TypeToken<Map<String, String>>() {}.type val mapType = object : TypeToken<Map<String, String>>() {}.type
val dict = gson.fromJson<Map<String, String>>(namesJson, mapType) val dict = gson.fromJson<Map<String, String>>(namesJson, mapType)
@@ -129,14 +134,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
private fun persist() { private fun persist() {
try { try {
val json = gson.toJson(_bookmarks.value) val json = gson.toJson(_bookmarks.value)
prefs.edit().putString(STORE_KEY, json).apply() storage.putString(STORE_KEY, json)
} catch (_: Exception) {} } catch (_: Exception) {}
} }
private fun persistNames() { private fun persistNames() {
try { try {
val json = gson.toJson(_bookmarkNames.value) val json = gson.toJson(_bookmarkNames.value)
prefs.edit().putString(NAMES_STORE_KEY, json).apply() storage.putString(NAMES_STORE_KEY, json)
} catch (_: Exception) {} } catch (_: Exception) {}
} }
@@ -147,10 +152,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
membership.clear() membership.clear()
_bookmarks.value = emptyList() _bookmarks.value = emptyList()
_bookmarkNames.value = emptyMap() _bookmarkNames.value = emptyMap()
prefs.edit() storage.clearForPanic()
.remove(STORE_KEY)
.remove(NAMES_STORE_KEY)
.apply()
// Clear any in-flight resolutions to avoid repopulating // Clear any in-flight resolutions to avoid repopulating
resolving.clear() resolving.clear()
Log.i(TAG, "Cleared all geohash bookmarks and names") 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<String>) { private fun persist(list: List<String>) {
try { try {
val json = gson.toJson(list) val json = gson.toJson(list)
prefs.edit().putString(STORE_KEY, json).apply() storage.putString(STORE_KEY, json)
} catch (_: Exception) {} } catch (_: Exception) {}
} }
private fun persistNames(map: Map<String, String>) { private fun persistNames(map: Map<String, String>) {
try { try {
val json = gson.toJson(map) val json = gson.toJson(map)
prefs.edit().putString(NAMES_STORE_KEY, json).apply() storage.putString(NAMES_STORE_KEY, json)
} catch (_: Exception) {} } catch (_: Exception) {}
} }
} }
@@ -381,7 +381,7 @@ class SecureIdentityStateManager(private val context: Context) {
return prefs.contains(KEY_STATIC_PRIVATE_KEY) && prefs.contains(KEY_STATIC_PUBLIC_KEY) 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 * Store a string value in secure preferences
@@ -1,33 +1,33 @@
package com.bitchat.android.net package com.bitchat.android.net
import android.content.Context 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
object TorPreferenceManager { object TorPreferenceManager {
private const val PREFS_NAME = "bitchat_settings"
private const val KEY_TOR_MODE = "tor_mode" private const val KEY_TOR_MODE = "tor_mode"
private val _modeFlow = MutableStateFlow(TorMode.ON) private val _modeFlow = MutableStateFlow(TorMode.ON)
val modeFlow: StateFlow<TorMode> = _modeFlow val modeFlow: StateFlow<TorMode> = _modeFlow
fun init(context: Context) { fun init(context: Context) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val storage = StorageModule.repository(context, StorageDefinitions.AppSettings)
val saved = prefs.getString(KEY_TOR_MODE, TorMode.ON.name) val saved = storage.getString(KEY_TOR_MODE, TorMode.ON.name)
val mode = runCatching { TorMode.valueOf(saved ?: TorMode.ON.name) }.getOrDefault(TorMode.ON) val mode = runCatching { TorMode.valueOf(saved ?: TorMode.ON.name) }.getOrDefault(TorMode.ON)
_modeFlow.value = mode _modeFlow.value = mode
} }
fun set(context: Context, mode: TorMode) { fun set(context: Context, mode: TorMode) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val storage = StorageModule.repository(context, StorageDefinitions.AppSettings)
prefs.edit().putString(KEY_TOR_MODE, mode.name).apply() storage.putString(KEY_TOR_MODE, mode.name)
_modeFlow.value = mode _modeFlow.value = mode
} }
fun get(context: Context): TorMode { fun get(context: Context): TorMode {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val storage = StorageModule.repository(context, StorageDefinitions.AppSettings)
val saved = prefs.getString(KEY_TOR_MODE, TorMode.ON.name) val saved = storage.getString(KEY_TOR_MODE, TorMode.ON.name)
return runCatching { TorMode.valueOf(saved ?: TorMode.ON.name) }.getOrDefault(TorMode.ON) return runCatching { TorMode.valueOf(saved ?: TorMode.ON.name) }.getOrDefault(TorMode.ON)
} }
} }
@@ -1,40 +1,61 @@
package com.bitchat.android.nostr package com.bitchat.android.nostr
import android.content.Context 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 import java.util.concurrent.ConcurrentHashMap
/**
* GeohashAliasRegistry
* - Global, thread-safe registry for alias->Nostr pubkey mappings (e.g., nostr_<pub16> -> pubkeyHex)
* - Persisted to SharedPreferences to survive app restarts.
*/
object GeohashAliasRegistry { object GeohashAliasRegistry {
private val map: MutableMap<String, String> = ConcurrentHashMap() private val map: MutableMap<String, String> = ConcurrentHashMap()
private const val PREFS_NAME = "geohash_alias_registry" 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) { fun initialize(context: Context) {
if (prefs == null) { if (storage == null) {
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val legacyPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
loadFromPrefs() 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() { private fun loadFromStorage() {
prefs?.let { p -> val repository = storage ?: return
val allEntries = p.all val type = object : TypeToken<Map<String, String>>() {}.type
for ((key, value) in allEntries) { repository.getJson<Map<String, String>>(ENTRIES_KEY, type)?.let { entries ->
if (key is String && value is String) { map.clear()
map[key] = value map.putAll(entries)
}
}
} }
} }
private fun persist() {
storage?.putJson(ENTRIES_KEY, map.toMap())
}
fun put(alias: String, pubkeyHex: String) { fun put(alias: String, pubkeyHex: String) {
map[alias] = pubkeyHex map[alias] = pubkeyHex
prefs?.edit()?.putString(alias, pubkeyHex)?.apply() persist()
} }
fun get(alias: String): String? = map[alias] fun get(alias: String): String? = map[alias]
@@ -45,6 +66,6 @@ object GeohashAliasRegistry {
fun clear() { fun clear() {
map.clear() map.clear()
prefs?.edit()?.clear()?.apply() storage?.clearForPanic()
} }
} }
@@ -1,42 +1,62 @@
package com.bitchat.android.nostr package com.bitchat.android.nostr
import android.content.Context 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 import java.util.concurrent.ConcurrentHashMap
/**
* GeohashConversationRegistry
* - Global, thread-safe registry of conversationKey (e.g., "nostr_<pub16>") -> source geohash
* - Enables routing geohash DMs from anywhere by providing the correct geohash identity
* - Persisted to SharedPreferences to survive app restarts.
*/
object GeohashConversationRegistry { object GeohashConversationRegistry {
private val map = ConcurrentHashMap<String, String>() private val map = ConcurrentHashMap<String, String>()
private const val PREFS_NAME = "geohash_conversation_registry" 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) { fun initialize(context: Context) {
if (prefs == null) { if (storage == null) {
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val legacyPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
loadFromPrefs() 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() { private fun loadFromStorage() {
prefs?.let { p -> val repository = storage ?: return
val allEntries = p.all val type = object : TypeToken<Map<String, String>>() {}.type
for ((key, value) in allEntries) { repository.getJson<Map<String, String>>(ENTRIES_KEY, type)?.let { entries ->
if (key is String && value is String) { map.clear()
map[key] = value map.putAll(entries)
}
}
} }
} }
private fun persist() {
storage?.putJson(ENTRIES_KEY, map.toMap())
}
fun set(convKey: String, geohash: String) { fun set(convKey: String, geohash: String) {
if (geohash.isNotEmpty()) { if (geohash.isNotEmpty()) {
map[convKey] = geohash map[convKey] = geohash
prefs?.edit()?.putString(convKey, geohash)?.apply() persist()
} }
} }
@@ -46,6 +66,6 @@ object GeohashConversationRegistry {
fun clear() { fun clear() {
map.clear() map.clear()
prefs?.edit()?.clear()?.apply() storage?.clearForPanic()
} }
} }
@@ -1,7 +1,9 @@
package com.bitchat.android.nostr package com.bitchat.android.nostr
import android.content.Context 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -11,7 +13,6 @@ import kotlinx.coroutines.flow.asStateFlow
*/ */
object PoWPreferenceManager { object PoWPreferenceManager {
private const val PREFS_NAME = "pow_preferences"
private const val KEY_POW_ENABLED = "pow_enabled" private const val KEY_POW_ENABLED = "pow_enabled"
private const val KEY_POW_DIFFICULTY = "pow_difficulty" private const val KEY_POW_DIFFICULTY = "pow_difficulty"
@@ -30,7 +31,7 @@ object PoWPreferenceManager {
private val _isMining = MutableStateFlow(false) private val _isMining = MutableStateFlow(false)
val isMining: StateFlow<Boolean> = _isMining.asStateFlow() val isMining: StateFlow<Boolean> = _isMining.asStateFlow()
private lateinit var sharedPrefs: SharedPreferences private lateinit var storage: StorageRepository
private var isInitialized = false private var isInitialized = false
/** /**
@@ -40,11 +41,11 @@ object PoWPreferenceManager {
fun init(context: Context) { fun init(context: Context) {
if (isInitialized) return if (isInitialized) return
sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) storage = StorageModule.repository(context, StorageDefinitions.PowPreferences)
// Load current values // Load current values
_powEnabled.value = sharedPrefs.getBoolean(KEY_POW_ENABLED, DEFAULT_POW_ENABLED) _powEnabled.value = storage.getBoolean(KEY_POW_ENABLED, DEFAULT_POW_ENABLED)
_powDifficulty.value = sharedPrefs.getInt(KEY_POW_DIFFICULTY, DEFAULT_POW_DIFFICULTY) _powDifficulty.value = storage.getInt(KEY_POW_DIFFICULTY, DEFAULT_POW_DIFFICULTY)
isInitialized = true isInitialized = true
} }
@@ -61,8 +62,8 @@ object PoWPreferenceManager {
*/ */
fun setPowEnabled(enabled: Boolean) { fun setPowEnabled(enabled: Boolean) {
_powEnabled.value = enabled _powEnabled.value = enabled
if (::sharedPrefs.isInitialized) { if (::storage.isInitialized) {
sharedPrefs.edit().putBoolean(KEY_POW_ENABLED, enabled).apply() storage.putBoolean(KEY_POW_ENABLED, enabled)
} }
} }
@@ -79,8 +80,8 @@ object PoWPreferenceManager {
fun setPowDifficulty(difficulty: Int) { fun setPowDifficulty(difficulty: Int) {
val clampedDifficulty = difficulty.coerceIn(0, 32) val clampedDifficulty = difficulty.coerceIn(0, 32)
_powDifficulty.value = clampedDifficulty _powDifficulty.value = clampedDifficulty
if (::sharedPrefs.isInitialized) { if (::storage.isInitialized) {
sharedPrefs.edit().putInt(KEY_POW_DIFFICULTY, clampedDifficulty).apply() storage.putInt(KEY_POW_DIFFICULTY, clampedDifficulty)
} }
} }
@@ -1,21 +1,19 @@
package com.bitchat.android.onboarding package com.bitchat.android.onboarding
import android.content.Context 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 { object BackgroundLocationPreferenceManager {
private const val PREFS_NAME = "bitchat_settings"
private const val KEY_BACKGROUND_LOCATION_SKIP = "background_location_skipped" private const val KEY_BACKGROUND_LOCATION_SKIP = "background_location_skipped"
fun setSkipped(context: Context, skipped: Boolean) { fun setSkipped(context: Context, skipped: Boolean) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val storage = StorageModule.repository(context, StorageDefinitions.AppSettings)
prefs.edit().putBoolean(KEY_BACKGROUND_LOCATION_SKIP, skipped).apply() storage.putBoolean(KEY_BACKGROUND_LOCATION_SKIP, skipped)
} }
fun isSkipped(context: Context): Boolean { fun isSkipped(context: Context): Boolean {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val storage = StorageModule.repository(context, StorageDefinitions.AppSettings)
return prefs.getBoolean(KEY_BACKGROUND_LOCATION_SKIP, false) return storage.getBoolean(KEY_BACKGROUND_LOCATION_SKIP, false)
} }
} }
@@ -1,33 +1,31 @@
package com.bitchat.android.onboarding package com.bitchat.android.onboarding
import android.content.Context 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/**
* Preference manager for battery optimization skip choice
*/
object BatteryOptimizationPreferenceManager { object BatteryOptimizationPreferenceManager {
private const val PREFS_NAME = "bitchat_settings"
private const val KEY_BATTERY_SKIP = "battery_optimization_skipped" private const val KEY_BATTERY_SKIP = "battery_optimization_skipped"
private val _skipFlow = MutableStateFlow(false) private val _skipFlow = MutableStateFlow(false)
val skipFlow: StateFlow<Boolean> = _skipFlow val skipFlow: StateFlow<Boolean> = _skipFlow
fun init(context: Context) { fun init(context: Context) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val storage = StorageModule.repository(context, StorageDefinitions.AppSettings)
val skipped = prefs.getBoolean(KEY_BATTERY_SKIP, false) val skipped = storage.getBoolean(KEY_BATTERY_SKIP, false)
_skipFlow.value = skipped _skipFlow.value = skipped
} }
fun setSkipped(context: Context, skipped: Boolean) { fun setSkipped(context: Context, skipped: Boolean) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val storage = StorageModule.repository(context, StorageDefinitions.AppSettings)
prefs.edit().putBoolean(KEY_BATTERY_SKIP, skipped).apply() storage.putBoolean(KEY_BATTERY_SKIP, skipped)
_skipFlow.value = skipped _skipFlow.value = skipped
} }
fun isSkipped(context: Context): Boolean { fun isSkipped(context: Context): Boolean {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val storage = StorageModule.repository(context, StorageDefinitions.AppSettings)
return prefs.getBoolean(KEY_BATTERY_SKIP, false) return storage.getBoolean(KEY_BATTERY_SKIP, false)
} }
} }
@@ -8,6 +8,8 @@ import android.os.PowerManager
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.bitchat.android.R import com.bitchat.android.R
import com.bitchat.android.storage.StorageDefinitions
import com.bitchat.android.storage.StorageModule
/** /**
* Centralized permission management for bitchat app * Centralized permission management for bitchat app
@@ -17,26 +19,23 @@ class PermissionManager(private val context: Context) {
companion object { companion object {
private const val TAG = "PermissionManager" private const val TAG = "PermissionManager"
private const val PREFS_NAME = "bitchat_permissions"
private const val KEY_FIRST_TIME_COMPLETE = "first_time_onboarding_complete" 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 * Check if this is the first time the user is launching the app
*/ */
fun isFirstTimeLaunch(): Boolean { 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 * Mark the first-time onboarding as complete
*/ */
fun markOnboardingComplete() { fun markOnboardingComplete() {
sharedPrefs.edit() storage.putBoolean(KEY_FIRST_TIME_COMPLETE, true)
.putBoolean(KEY_FIRST_TIME_COMPLETE, true)
.apply()
Log.d(TAG, "First-time onboarding marked as complete") Log.d(TAG, "First-time onboarding marked as complete")
} }
@@ -125,14 +125,20 @@ class MeshForegroundService : Service() {
notificationManager = NotificationManagerCompat.from(this) notificationManager = NotificationManagerCompat.from(this)
createChannel() 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 val existing = MeshServiceHolder.meshService
if (existing != null) { if (existing != null) {
Log.d("MeshForegroundService", "Using existing BluetoothMeshService from holder") Log.d("MeshForegroundService", "Using existing BluetoothMeshService from holder")
} else { } else if (isForegroundMeshEligible()) {
val created = MeshServiceHolder.getOrCreate(applicationContext) val created = MeshServiceHolder.getOrCreate(applicationContext)
Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder") Log.i("MeshForegroundService", "Created new BluetoothMeshService via holder")
MeshServiceHolder.attach(created) 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) { when (intent?.action) {
ACTION_STOP -> { ACTION_STOP -> {
// Stop FGS and mesh cleanly // Stop FGS and mesh cleanly
try { meshService?.stopServices() } catch (_: Exception) { } updateJob?.cancel()
try { MeshServiceHolder.clear() } catch (_: Exception) { } updateJob = null
try { stopForeground(true) } catch (_: Exception) { } ensureMeshStopped("stop requested")
notificationManager.cancel(NOTIFICATION_ID) clearForegroundNotification(removeNotification = true)
isInForeground = false
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -177,7 +182,7 @@ class MeshForegroundService : Service() {
} }
ACTION_UPDATE_NOTIFICATION -> { ACTION_UPDATE_NOTIFICATION -> {
// If we became eligible and are not in foreground yet, promote once // 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) val n = buildNotification(meshService?.getActivePeerCount() ?: 0)
startForegroundCompat(n) startForegroundCompat(n)
isInForeground = true isInForeground = true
@@ -188,11 +193,18 @@ class MeshForegroundService : Service() {
else -> { /* ACTION_START or null */ } 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) // Ensure mesh is running (only after permissions are granted)
ensureMeshStarted() ensureMeshStarted()
// Promote exactly once when eligible, otherwise stay background (or stop) // 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) val notification = buildNotification(meshService?.getActivePeerCount() ?: 0)
startForegroundCompat(notification) startForegroundCompat(notification)
isInForeground = true isInForeground = true
@@ -203,18 +215,16 @@ class MeshForegroundService : Service() {
updateJob = scope.launch { updateJob = scope.launch {
while (isActive) { while (isActive) {
// Retry enabling mesh/foreground once permissions become available // Retry enabling mesh/foreground once permissions become available
ensureMeshStarted() val eligible = isForegroundMeshEligible()
val eligible = MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()
if (eligible) { if (eligible) {
ensureMeshStarted()
// Only update the notification; do not re-call startForeground() // Only update the notification; do not re-call startForeground()
updateNotification(force = false) updateNotification(force = false)
} else { } else {
// If disabled or perms missing, ensure we are not in foreground and clear notif ensureMeshStopped("foreground mesh eligibility lost")
if (isInForeground) { clearForegroundNotification(removeNotification = true)
try { stopForeground(false) } catch (_: Exception) { } stopSelf()
isInForeground = false break
}
notificationManager.cancel(NOTIFICATION_ID)
} }
delay(5000) delay(5000)
} }
@@ -226,7 +236,10 @@ class MeshForegroundService : Service() {
private fun ensureMeshStarted() { private fun ensureMeshStarted() {
if (isShuttingDown) return if (isShuttingDown) return
if (!hasBluetoothPermissions()) return if (!isForegroundMeshEligible()) {
ensureMeshStopped("foreground mesh not eligible")
return
}
try { try {
android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started")
val service = MeshServiceHolder.getOrCreate(applicationContext) 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) { private fun updateNotification(force: Boolean) {
if (isShuttingDown) { if (isShuttingDown) {
notificationManager.cancel(NOTIFICATION_ID) notificationManager.cancel(NOTIFICATION_ID)
@@ -243,16 +272,23 @@ class MeshForegroundService : Service() {
} }
val count = meshService?.getActivePeerCount() ?: 0 val count = meshService?.getActivePeerCount() ?: 0
val notification = buildNotification(count) val notification = buildNotification(count)
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { if (isForegroundMeshEligible()) {
notificationManager.notify(NOTIFICATION_ID, notification) notificationManager.notify(NOTIFICATION_ID, notification)
} else if (force) { } else if (force) {
// If disabled and forced, make sure to remove any prior foreground state // If disabled and forced, make sure to remove any prior foreground state
try { stopForeground(false) } catch (_: Exception) { } ensureMeshStopped("notification update while ineligible")
notificationManager.cancel(NOTIFICATION_ID) clearForegroundNotification(removeNotification = true)
isInForeground = false
} }
} }
private fun isForegroundMeshEligible(): Boolean {
return isBackgroundMeshEnabled() && hasAllRequiredPermissions()
}
private fun isBackgroundMeshEnabled(): Boolean {
return MeshServicePreferences.isBackgroundEnabled(true)
}
private fun hasAllRequiredPermissions(): Boolean { private fun hasAllRequiredPermissions(): Boolean {
// For starting FGS with connectedDevice|dataSync, we need: // For starting FGS with connectedDevice|dataSync, we need:
// - Foreground service permissions (declared in manifest) // - Foreground service permissions (declared in manifest)
@@ -280,6 +316,16 @@ class MeshForegroundService : Service() {
} else true } 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 { private fun buildNotification(activePeers: Int): Notification {
val openIntent = Intent(this, MainActivity::class.java) val openIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(
@@ -362,13 +408,13 @@ class MeshForegroundService : Service() {
override fun onDestroy() { override fun onDestroy() {
updateJob?.cancel() updateJob?.cancel()
updateJob = null updateJob = null
if (isShuttingDown || !isForegroundMeshEligible()) {
ensureMeshStopped("service destroyed")
}
// Cancel the service coroutine scope to prevent leaks // Cancel the service coroutine scope to prevent leaks
try { serviceJob.cancel() } catch (_: Exception) { } try { serviceJob.cancel() } catch (_: Exception) { }
// Best-effort ensure we are not marked foreground // Best-effort ensure we are not marked foreground
if (isInForeground) { clearForegroundNotification(removeNotification = true)
try { stopForeground(true) } catch (_: Exception) { }
isInForeground = false
}
super.onDestroy() super.onDestroy()
} }
@@ -1,32 +1,35 @@
package com.bitchat.android.service package com.bitchat.android.service
import android.content.Context 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 { 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_AUTO_START = "auto_start_on_boot"
private const val KEY_BACKGROUND_ENABLED = "background_enabled" private const val KEY_BACKGROUND_ENABLED = "background_enabled"
private lateinit var prefs: SharedPreferences private lateinit var storage: StorageRepository
fun init(context: Context) { 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 { 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) { 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 { 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) { fun setBackgroundEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_BACKGROUND_ENABLED, enabled).apply() if (ready()) storage.putBoolean(KEY_BACKGROUND_ENABLED, enabled)
} }
} }
@@ -2,8 +2,9 @@ package com.bitchat.android.services
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.storage.PanicClearRegistry
import com.google.gson.Gson import com.bitchat.android.storage.StorageDefinitions
import com.bitchat.android.storage.StorageModule
/** /**
* Persistent store for message IDs we've already acknowledged (DELIVERED) or READ. * 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 storage = StorageModule.repository(context, StorageDefinitions.SeenMessages)
private val secure = SecureIdentityStateManager(context)
private val delivered = LinkedHashSet<String>(MAX_IDS) private val delivered = LinkedHashSet<String>(MAX_IDS)
private val read = LinkedHashSet<String>(MAX_IDS) private val read = LinkedHashSet<String>(MAX_IDS)
init { load() } init {
PanicClearRegistry.register(StorageDefinitions.SeenMessages) { clear() }
load()
}
@Synchronized fun hasDelivered(id: String) = delivered.contains(id) @Synchronized fun hasDelivered(id: String) = delivered.contains(id)
@Synchronized fun hasRead(id: String) = read.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() { @Synchronized fun clear() {
delivered.clear() delivered.clear()
read.clear() read.clear()
persist() storage.clearForPanic()
} }
private fun trim(set: LinkedHashSet<String>) { private fun trim(set: LinkedHashSet<String>) {
@@ -66,8 +69,7 @@ class SeenMessageStore private constructor(private val context: Context) {
@Synchronized private fun load() { @Synchronized private fun load() {
try { try {
val json = secure.getSecureValue(STORAGE_KEY) ?: return val data = storage.getJson(STORAGE_KEY, StorePayload::class.java) ?: return
val data = gson.fromJson(json, StorePayload::class.java) ?: return
delivered.clear(); read.clear() delivered.clear(); read.clear()
data.delivered.takeLast(MAX_IDS).forEach { delivered.add(it) } data.delivered.takeLast(MAX_IDS).forEach { delivered.add(it) }
data.read.takeLast(MAX_IDS).forEach { read.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() { @Synchronized private fun persist() {
try { try {
val payload = StorePayload(delivered.toList(), read.toList()) val payload = StorePayload(delivered.toList(), read.toList())
val json = gson.toJson(payload) storage.putJson(STORAGE_KEY, payload)
secure.storeSecureValue(STORAGE_KEY, json)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to persist SeenMessageStore: ${e.message}") Log.e(TAG, "Failed to persist SeenMessageStore: ${e.message}")
} }
@@ -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<String> = emptySet()): Set<String>
fun putStringSet(key: String, value: Set<String>)
fun contains(key: String): Boolean
fun remove(key: String)
fun clear()
}
@@ -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<String, PanicClearEntry>()
@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<PanicClearEntry> = entries.values.toList()
fun clearAll(): List<PanicClearResult> {
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()
}
}
@@ -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<String>): Set<String> {
return getString(key)?.split(STRING_SET_SEPARATOR)?.filter { it.isNotEmpty() }?.toSet()
?: defaultValue
}
override fun putStringSet(key: String, value: Set<String>) {
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"
}
}
@@ -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<String>): Set<String> {
return prefs.getStringSet(key, defaultValue)?.toSet() ?: defaultValue
}
override fun putStringSet(key: String, value: Set<String>) {
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() }
}
}
@@ -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<String> = 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
)
}
@@ -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<StorageMigration> = 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)
}
}
}
@@ -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<String> = emptySet()): Set<String> {
return store.getStringSet(key, defaultValue)
}
fun putStringSet(key: String, value: Set<String>) = store.putStringSet(key, value)
fun remove(key: String) = store.remove(key)
fun contains(key: String): Boolean = store.contains(key)
fun <T> getJson(key: String, type: Type): T? {
val json = store.getString(key, null) ?: return null
return runCatching { gson.fromJson<T>(json, type) }.getOrNull()
}
fun <T> getJson(key: String, clazz: Class<T>): 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<StorageMigration>) {
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"
}
}
@@ -5,7 +5,6 @@ import android.util.Log
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitchat.android.favorites.FavoritesPersistenceService
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -21,13 +20,13 @@ import com.bitchat.android.protocol.BitchatPacket
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.bitchat.android.util.NotificationIntervalManager import com.bitchat.android.util.NotificationIntervalManager
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.Date import java.util.Date
import kotlin.random.Random import kotlin.random.Random
import com.bitchat.android.services.VerificationService import com.bitchat.android.services.VerificationService
import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.noise.NoiseSession 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.dataFromHexString
import com.bitchat.android.util.hexEncodedString import com.bitchat.android.util.hexEncodedString
@@ -910,12 +909,18 @@ class ChatViewModel(
messageManager.clearAllMessages() messageManager.clearAllMessages()
channelManager.clearAllChannels() channelManager.clearAllChannels()
privateChatManager.clearAllPrivateChats() privateChatManager.clearAllPrivateChats()
dataManager.clearAllData()
// Clear seen message store
try { try {
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() StorageModule.registerKnownPanicStores(getApplication())
} catch (_: Exception) { } } 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 // Clear all mesh service data
clearAllMeshServiceData() clearAllMeshServiceData()
@@ -929,14 +934,8 @@ class ChatViewModel(
// Clear all media files // Clear all media files
com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication()) 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 { 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() geohashViewModel.panicReset()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
@@ -1006,20 +1005,10 @@ class ChatViewModel(
try { try {
val identityManager = SecureIdentityStateManager(getApplication()) val identityManager = SecureIdentityStateManager(getApplication())
identityManager.clearIdentityData() identityManager.clearIdentityData()
// Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) Log.d(TAG, "✅ Cleared secure identity state")
try {
identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index")
} catch (_: Exception) { }
Log.d(TAG, "✅ Cleared secure identity state and secure favorites store")
} catch (e: Exception) { } catch (e: Exception) {
Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}") 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") Log.d(TAG, "✅ Cleared all cryptographic data")
} catch (e: Exception) { } catch (e: Exception) {
@@ -1,8 +1,10 @@
package com.bitchat.android.ui package com.bitchat.android.ui
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.util.Log 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.Gson
import kotlin.random.Random import kotlin.random.Random
@@ -15,7 +17,7 @@ class DataManager(private val context: Context) {
private const val TAG = "DataManager" 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() private val gson = Gson()
// Channel-related maps that need to persist state // Channel-related maps that need to persist state
@@ -28,11 +30,15 @@ class DataManager(private val context: Context) {
val favoritePeers: Set<String> get() = _favoritePeers val favoritePeers: Set<String> get() = _favoritePeers
val blockedUsers: Set<String> get() = _blockedUsers val blockedUsers: Set<String> get() = _blockedUsers
val channelMembers: Map<String, MutableSet<String>> get() = _channelMembers val channelMembers: Map<String, MutableSet<String>> get() = _channelMembers
init {
PanicClearRegistry.register(StorageDefinitions.Chat) { clearAllData() }
}
// MARK: - Nickname Management // MARK: - Nickname Management
fun loadNickname(): String { fun loadNickname(): String {
val savedNickname = prefs.getString("nickname", null) val savedNickname = storage.getString("nickname", null)
return if (savedNickname != null) { return if (savedNickname != null) {
savedNickname savedNickname
} else { } else {
@@ -43,47 +49,47 @@ class DataManager(private val context: Context) {
} }
fun saveNickname(nickname: String) { fun saveNickname(nickname: String) {
prefs.edit().putString("nickname", nickname).apply() storage.putString("nickname", nickname)
} }
// MARK: - Geohash Channel Persistence // MARK: - Geohash Channel Persistence
fun loadLastGeohashChannel(): String? { fun loadLastGeohashChannel(): String? {
return prefs.getString("last_geohash_channel", null) return storage.getString("last_geohash_channel", null)
} }
fun saveLastGeohashChannel(channelData: String) { 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") Log.d(TAG, "Saved last geohash channel: $channelData")
} }
fun clearLastGeohashChannel() { fun clearLastGeohashChannel() {
prefs.edit().remove("last_geohash_channel").apply() storage.remove("last_geohash_channel")
Log.d(TAG, "Cleared last geohash channel") Log.d(TAG, "Cleared last geohash channel")
} }
// MARK: - Location Services State // MARK: - Location Services State
fun saveLocationServicesEnabled(enabled: Boolean) { 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") Log.d(TAG, "Saved location services enabled state: $enabled")
} }
fun isLocationServicesEnabled(): Boolean { 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 // MARK: - Channel Data Management
fun loadChannelData(): Pair<Set<String>, Set<String>> { fun loadChannelData(): Pair<Set<String>, Set<String>> {
// Load joined channels // Load joined channels
val savedChannels = prefs.getStringSet("joined_channels", emptySet()) ?: emptySet() val savedChannels = storage.getStringSet("joined_channels", emptySet())
// Load password protected channels // Load password protected channels
val savedProtectedChannels = prefs.getStringSet("password_protected_channels", emptySet()) ?: emptySet() val savedProtectedChannels = storage.getStringSet("password_protected_channels", emptySet())
// Load channel creators // Load channel creators
val creatorsJson = prefs.getString("channel_creators", "{}") val creatorsJson = storage.getString("channel_creators", "{}")
try { try {
val creatorsMap = gson.fromJson(creatorsJson, Map::class.java) as? Map<String, String> val creatorsMap = gson.fromJson(creatorsJson, Map::class.java) as? Map<String, String>
creatorsMap?.let { _channelCreators.putAll(it) } creatorsMap?.let { _channelCreators.putAll(it) }
@@ -102,12 +108,9 @@ class DataManager(private val context: Context) {
} }
fun saveChannelData(joinedChannels: Set<String>, passwordProtectedChannels: Set<String>) { fun saveChannelData(joinedChannels: Set<String>, passwordProtectedChannels: Set<String>) {
prefs.edit().apply { storage.putStringSet("joined_channels", joinedChannels)
putStringSet("joined_channels", joinedChannels) storage.putStringSet("password_protected_channels", passwordProtectedChannels)
putStringSet("password_protected_channels", passwordProtectedChannels) storage.putString("channel_creators", gson.toJson(_channelCreators))
putString("channel_creators", gson.toJson(_channelCreators))
apply()
}
} }
fun addChannelCreator(channel: String, creatorID: String) { fun addChannelCreator(channel: String, creatorID: String) {
@@ -156,13 +159,13 @@ class DataManager(private val context: Context) {
// MARK: - Favorites Management // MARK: - Favorites Management
fun loadFavorites() { fun loadFavorites() {
val savedFavorites = prefs.getStringSet("favorites", emptySet()) ?: emptySet() val savedFavorites = storage.getStringSet("favorites", emptySet())
_favoritePeers.addAll(savedFavorites) _favoritePeers.addAll(savedFavorites)
Log.d(TAG, "Loaded ${savedFavorites.size} favorite users from storage: $savedFavorites") Log.d(TAG, "Loaded ${savedFavorites.size} favorite users from storage: $savedFavorites")
} }
fun saveFavorites() { fun saveFavorites() {
prefs.edit().putStringSet("favorites", _favoritePeers).apply() storage.putStringSet("favorites", _favoritePeers)
Log.d(TAG, "Saved ${_favoritePeers.size} favorite users to storage: $_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 // MARK: - Blocked Users Management
fun loadBlockedUsers() { fun loadBlockedUsers() {
val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet() val savedBlockedUsers = storage.getStringSet("blocked_users", emptySet())
_blockedUsers.addAll(savedBlockedUsers) _blockedUsers.addAll(savedBlockedUsers)
} }
fun saveBlockedUsers() { fun saveBlockedUsers() {
prefs.edit().putStringSet("blocked_users", _blockedUsers).apply() storage.putStringSet("blocked_users", _blockedUsers)
} }
fun addBlockedUser(fingerprint: String) { fun addBlockedUser(fingerprint: String) {
@@ -226,12 +229,12 @@ class DataManager(private val context: Context) {
val geohashBlockedUsers: Set<String> get() = _geohashBlockedUsers.toSet() val geohashBlockedUsers: Set<String> get() = _geohashBlockedUsers.toSet()
fun loadGeohashBlockedUsers() { fun loadGeohashBlockedUsers() {
val savedGeohashBlockedUsers = prefs.getStringSet("geohash_blocked_users", emptySet()) ?: emptySet() val savedGeohashBlockedUsers = storage.getStringSet("geohash_blocked_users", emptySet())
_geohashBlockedUsers.addAll(savedGeohashBlockedUsers) _geohashBlockedUsers.addAll(savedGeohashBlockedUsers)
} }
fun saveGeohashBlockedUsers() { fun saveGeohashBlockedUsers() {
prefs.edit().putStringSet("geohash_blocked_users", _geohashBlockedUsers).apply() storage.putStringSet("geohash_blocked_users", _geohashBlockedUsers)
} }
fun addGeohashBlockedUser(pubkeyHex: String) { fun addGeohashBlockedUser(pubkeyHex: String) {
@@ -256,6 +259,6 @@ class DataManager(private val context: Context) {
_blockedUsers.clear() _blockedUsers.clear()
_geohashBlockedUsers.clear() _geohashBlockedUsers.clear()
_channelMembers.clear() _channelMembers.clear()
prefs.edit().clear().apply() storage.clearForPanic()
} }
} }
@@ -160,8 +160,6 @@ class GeohashViewModel(
fun panicReset() { fun panicReset() {
repo.clearAll() repo.clearAll()
GeohashAliasRegistry.clear()
GeohashConversationRegistry.clear()
subscriptionManager.disconnect() subscriptionManager.disconnect()
currentGeohashSubId = null currentGeohashSubId = null
currentDmSubId = null currentDmSubId = null
@@ -1,14 +1,11 @@
package com.bitchat.android.ui.debug package com.bitchat.android.ui.debug
import android.content.Context 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 { object DebugPreferenceManager {
private const val PREFS_NAME = "bitchat_debug_settings"
private const val KEY_VERBOSE = "verbose_logging" private const val KEY_VERBOSE = "verbose_logging"
private const val KEY_GATT_SERVER = "gatt_server_enabled" private const val KEY_GATT_SERVER = "gatt_server_enabled"
private const val KEY_GATT_CLIENT = "gatt_client_enabled" private const val KEY_GATT_CLIENT = "gatt_client_enabled"
@@ -20,87 +17,82 @@ object DebugPreferenceManager {
// GCS keys (no migration/back-compat) // GCS keys (no migration/back-compat)
private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes" private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes"
private const val KEY_GCS_FPR = "gcs_filter_fpr_percent" 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) { 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 = 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) { 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 = 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) { 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 = 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) { 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 = 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) { 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 = 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) { 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 = 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) { 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 = 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) { 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 = 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) { 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 = 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) { 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 = 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) { 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.
} }
@@ -1,6 +1,8 @@
package com.bitchat.android.ui.theme package com.bitchat.android.ui.theme
import android.content.Context 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -17,26 +19,21 @@ enum class ThemePreference {
val isDark : Boolean get() = this == Dark val isDark : Boolean get() = this == Dark
} }
/**
* Simple SharedPreferences-backed manager for theme preference with a StateFlow.
* Avoids adding DataStore dependency for now.
*/
object ThemePreferenceManager { object ThemePreferenceManager {
private const val PREFS_NAME = "bitchat_settings"
private const val KEY_THEME = "theme_preference" private const val KEY_THEME = "theme_preference"
private val _themeFlow = MutableStateFlow(ThemePreference.System) private val _themeFlow = MutableStateFlow(ThemePreference.System)
val themeFlow: StateFlow<ThemePreference> = _themeFlow val themeFlow: StateFlow<ThemePreference> = _themeFlow
fun init(context: Context) { fun init(context: Context) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val storage = StorageModule.repository(context, StorageDefinitions.AppSettings)
val saved = prefs.getString(KEY_THEME, ThemePreference.System.name) val saved = storage.getString(KEY_THEME, ThemePreference.System.name)
_themeFlow.value = runCatching { ThemePreference.valueOf(saved!!) }.getOrDefault(ThemePreference.System) _themeFlow.value = runCatching { ThemePreference.valueOf(saved!!) }.getOrDefault(ThemePreference.System)
} }
fun set(context: Context, preference: ThemePreference) { fun set(context: Context, preference: ThemePreference) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val storage = StorageModule.repository(context, StorageDefinitions.AppSettings)
prefs.edit().putString(KEY_THEME, preference.name).apply() storage.putString(KEY_THEME, preference.name)
_themeFlow.value = preference _themeFlow.value = preference
} }
} }
@@ -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<Int>()
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<String>()
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<String> = 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<String, Any>()
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<String>): Set<String> {
@Suppress("UNCHECKED_CAST")
return (values[key] as? Set<String>)?.toSet() ?: defaultValue
}
override fun putStringSet(key: String, value: Set<String>) {
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()
}
}
}