diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca87d8dce..164655130 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,8 +1,9 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) } android { @@ -82,13 +83,13 @@ dependencies { // Cryptography implementation(libs.bundles.cryptography) - - // JSON - implementation(libs.gson) - + // Coroutines implementation(libs.kotlinx.coroutines.android) + // Serialization + implementation(libs.kotlinx.serialization.json) + // Bluetooth implementation(libs.nordic.ble) @@ -112,4 +113,12 @@ dependencies { androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.bundles.compose.testing) debugImplementation(libs.androidx.compose.ui.tooling) + + // Koin + implementation(libs.koin.core) + implementation(libs.koin.android) + implementation(libs.koin.compose) + implementation(libs.koin.annotation) + implementation(libs.koin.jsr330) + ksp(libs.koin.annotation.compiler) } diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index df9cd6e5b..8a5255107 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -1,9 +1,10 @@ package com.bitchat.android import android.app.Application -import com.bitchat.android.nostr.RelayDirectory +import com.bitchat.android.di.initKoin import com.bitchat.android.ui.theme.ThemePreferenceManager -import com.bitchat.android.net.TorManager +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger /** * Main application class for bitchat Android @@ -12,20 +13,11 @@ class BitchatApplication : Application() { override fun onCreate() { super.onCreate() - - // Initialize Tor first so any early network goes over Tor - try { TorManager.init(this) } catch (_: Exception) { } - // Initialize relay directory (loads assets/nostr_relays.csv) - RelayDirectory.initialize(this) - - // Initialize LocationNotesManager dependencies early so sheet subscriptions can start immediately - try { com.bitchat.android.nostr.LocationNotesInitializer.initialize(this) } catch (_: Exception) { } - - // Initialize favorites persistence early so MessageRouter/NostrTransport can use it on startup - try { - com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this) - } catch (_: Exception) { } + initKoin{ + androidContext(this@BitchatApplication) + androidLogger() + } // Warm up Nostr identity to ensure npub is available for favorite notifications try { @@ -34,10 +26,5 @@ class BitchatApplication : Application() { // Initialize theme preference ThemePreferenceManager.init(this) - - // Initialize debug preference manager (persists debug toggles) - try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { } - - // TorManager already initialized above } } diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 28d672802..8d7501cd0 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -7,6 +7,7 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels +import org.koin.androidx.viewmodel.ext.android.viewModel import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme @@ -42,26 +43,21 @@ import com.bitchat.android.ui.theme.BitchatTheme import com.bitchat.android.nostr.PoWPreferenceManager import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject class MainActivity : OrientationAwareActivity() { - private lateinit var permissionManager: PermissionManager + private val permissionManager: PermissionManager by inject() private lateinit var onboardingCoordinator: OnboardingCoordinator private lateinit var bluetoothStatusManager: BluetoothStatusManager private lateinit var locationStatusManager: LocationStatusManager private lateinit var batteryOptimizationManager: BatteryOptimizationManager // Core mesh service - managed at app level - private lateinit var meshService: BluetoothMeshService + private val meshService: BluetoothMeshService by inject() + private val mainViewModel: MainViewModel by viewModels() - private val chatViewModel: ChatViewModel by viewModels { - object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") - return ChatViewModel(application, meshService) as T - } - } - } + private val chatViewModel: ChatViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -69,10 +65,7 @@ class MainActivity : OrientationAwareActivity() { // Enable edge-to-edge display for modern Android look enableEdgeToEdge() - // Initialize permission management - permissionManager = PermissionManager(this) - // Initialize core mesh service first - meshService = BluetoothMeshService(this) + // Initialize core mesh service first - retrieve from Koin bluetoothStatusManager = BluetoothStatusManager( activity = this, context = this, @@ -593,13 +586,6 @@ class MainActivity : OrientationAwareActivity() { Log.d("MainActivity", "Permissions verified, initializing chat system") - // Initialize PoW preferences early in the initialization process - PoWPreferenceManager.init(this@MainActivity) - Log.d("MainActivity", "PoW preferences initialized") - - // Initialize Location Notes Manager (extracted to separate file) - com.bitchat.android.nostr.LocationNotesInitializer.initialize(this@MainActivity) - // Ensure all permissions are still granted (user might have revoked in settings) if (!permissionManager.areAllPermissionsGranted()) { val missing = permissionManager.getMissingPermissions() diff --git a/app/src/main/java/com/bitchat/android/MainViewModel.kt b/app/src/main/java/com/bitchat/android/MainViewModel.kt index 35125d855..7cae839f4 100644 --- a/app/src/main/java/com/bitchat/android/MainViewModel.kt +++ b/app/src/main/java/com/bitchat/android/MainViewModel.kt @@ -8,7 +8,9 @@ import com.bitchat.android.onboarding.BatteryOptimizationStatus import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import org.koin.android.annotation.KoinViewModel +@KoinViewModel class MainViewModel : ViewModel() { private val _onboardingState = MutableStateFlow(OnboardingState.CHECKING) diff --git a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt index 449d705f5..4c8849d80 100644 --- a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt @@ -3,6 +3,9 @@ package com.bitchat.android.crypto import android.content.Context import android.util.Log import com.bitchat.android.noise.NoiseEncryptionService +import com.bitchat.android.mesh.PeerFingerprintManager +import jakarta.inject.Inject +import jakarta.inject.Singleton import org.bouncycastle.crypto.AsymmetricCipherKeyPair import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters @@ -19,16 +22,17 @@ import java.util.concurrent.ConcurrentHashMap * This is the main interface for all encryption/decryption operations in bitchat. * It now uses the Noise protocol for secure transport encryption with proper session management. */ -class EncryptionService(private val context: Context) { +@Singleton +class EncryptionService @Inject constructor( + private val context: Context, + private val noiseService: NoiseEncryptionService, +) { companion object { private const val TAG = "EncryptionService" private const val ED25519_PRIVATE_KEY_PREF = "ed25519_signing_private_key" } - // Core Noise encryption service - private val noiseService: NoiseEncryptionService = NoiseEncryptionService(context) - // Session tracking for established connections private val establishedSessions = ConcurrentHashMap() // peerID -> fingerprint diff --git a/app/src/main/java/com/bitchat/android/di/AppModule.kt b/app/src/main/java/com/bitchat/android/di/AppModule.kt new file mode 100644 index 000000000..e512c72e0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/di/AppModule.kt @@ -0,0 +1,8 @@ +package com.bitchat.android.di + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module + +@Module +@ComponentScan("com.bitchat.android") +class AppModule diff --git a/app/src/main/java/com/bitchat/android/di/InitKoin.kt b/app/src/main/java/com/bitchat/android/di/InitKoin.kt new file mode 100644 index 000000000..a6a6a165f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/di/InitKoin.kt @@ -0,0 +1,14 @@ +package com.bitchat.android.di + +import org.koin.core.context.startKoin +import org.koin.dsl.KoinAppDeclaration +import org.koin.ksp.generated.module + +fun initKoin(config: KoinAppDeclaration? = null) { + startKoin { + modules( + AppModule().module + ) + config?.invoke(this) + } +} \ No newline at end of file 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 12cd3e9f4..060713f35 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -1,10 +1,13 @@ package com.bitchat.android.favorites -import android.content.Context import android.util.Log import com.bitchat.android.identity.SecureIdentityStateManager -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import com.bitchat.android.util.JsonUtil +import jakarta.inject.Inject +import jakarta.inject.Singleton import java.util.* /** @@ -57,32 +60,18 @@ interface FavoritesChangeListener { * Manages favorites with Noise↔Nostr mapping * Singleton pattern matching iOS implementation. */ -class FavoritesPersistenceService private constructor(private val context: Context) { +@Singleton +class FavoritesPersistenceService @Inject constructor( + private val stateManager : SecureIdentityStateManager +) { companion object { private const val TAG = "FavoritesPersistenceService" private const val FAVORITES_KEY = "favorite_relationships" // noiseHex -> relationship private const val PEERID_INDEX_KEY = "favorite_peerid_index" // peerID(16-hex) -> npub - - @Volatile - private var INSTANCE: FavoritesPersistenceService? = null - - val shared: FavoritesPersistenceService - get() = INSTANCE ?: throw IllegalStateException("FavoritesPersistenceService not initialized") - - fun initialize(context: Context) { - if (INSTANCE == null) { - synchronized(this) { - if (INSTANCE == null) { - INSTANCE = FavoritesPersistenceService(context.applicationContext) - } - } - } - } } - private val stateManager = SecureIdentityStateManager(context) - private val gson = Gson() + 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 @@ -161,7 +150,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte fun findPeerIDForNostrPubkey(nostrPubkey: String): String? { // First, try direct match in peerIdIndex (values are stored as npub strings) peerIdIndex.entries.firstOrNull { it.value.equals(nostrPubkey, ignoreCase = true) }?.let { return it.key } - + // Attempt legacy mapping via favorites Noise key association val targetHex = normalizeNostrKeyToHex(nostrPubkey) if (targetHex != null) { @@ -258,14 +247,14 @@ class FavoritesPersistenceService private constructor(private val context: Conte try { val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY) if (favoritesJson != null) { - val type = object : TypeToken>() {}.type - val data: Map = gson.fromJson(favoritesJson, type) - - favorites.clear() - data.forEach { (key, relationshipData) -> - favorites[key] = relationshipData.toFavoriteRelationship() + val data = JsonUtil.fromJsonOrNull(MapSerializer(String.serializer(), FavoriteRelationshipData.serializer()), favoritesJson) + if (data != null) { + favorites.clear() + data.forEach { (key, relationshipData) -> + favorites[key] = relationshipData.toFavoriteRelationship() + } + Log.d(TAG, "Loaded ${favorites.size} favorite relationships") } - Log.d(TAG, "Loaded ${favorites.size} favorite relationships") } } catch (e: Exception) { Log.e(TAG, "Failed to load favorites: ${e.message}") @@ -277,7 +266,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte val data = favorites.mapValues { (_, relationship) -> FavoriteRelationshipData.fromFavoriteRelationship(relationship) } - val favoritesJson = gson.toJson(data) + val favoritesJson = JsonUtil.toJson(MapSerializer(String.serializer(), FavoriteRelationshipData.serializer()), data) stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson) Log.d(TAG, "Saved ${favorites.size} favorite relationships") } catch (e: Exception) { @@ -289,10 +278,11 @@ class FavoritesPersistenceService private constructor(private val context: Conte try { val json = stateManager.getSecureValue(PEERID_INDEX_KEY) if (json != null) { - val type = object : TypeToken>() {}.type - val data: Map = gson.fromJson(json, type) - peerIdIndex.clear() - peerIdIndex.putAll(data) + val data = JsonUtil.fromJsonOrNull(MapSerializer(String.serializer(), String.serializer()), json) + if (data != null) { + peerIdIndex.clear() + peerIdIndex.putAll(data) + } Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings") } } catch (e: Exception) { @@ -302,7 +292,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte private fun savePeerIdIndex() { try { - val json = gson.toJson(peerIdIndex) + val json = JsonUtil.toJson(MapSerializer(String.serializer(), String.serializer()), peerIdIndex) stateManager.storeSecureValue(PEERID_INDEX_KEY, json) Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings") } catch (e: Exception) { @@ -336,6 +326,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte } /** Serializable data for JSON storage */ +@Serializable private data class FavoriteRelationshipData( val peerNoisePublicKeyHex: String, val peerNostrPublicKey: String?, 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 b498dd833..d95f69e37 100644 --- a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt +++ b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt @@ -7,32 +7,30 @@ import android.location.LocationManager import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import com.bitchat.android.util.JsonUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.Locale +import jakarta.inject.Inject +import jakarta.inject.Singleton /** * 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) { +@Singleton +class GeohashBookmarksStore @Inject constructor(private val context: Context) { companion object { private const val TAG = "GeohashBookmarksStore" private const val STORE_KEY = "locationChannel.bookmarks" private const val NAMES_STORE_KEY = "locationChannel.bookmarkNames" - @Volatile private var INSTANCE: GeohashBookmarksStore? = null - fun getInstance(context: Context): GeohashBookmarksStore { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: GeohashBookmarksStore(context.applicationContext).also { INSTANCE = it } - } - } - private val allowedChars = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() fun normalize(raw: String): String { return raw.trim().lowercase(Locale.US) @@ -41,7 +39,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 membership = mutableSetOf() @@ -96,19 +94,20 @@ class GeohashBookmarksStore private constructor(private val context: Context) { try { val arrJson = prefs.getString(STORE_KEY, null) if (!arrJson.isNullOrEmpty()) { - val listType = object : TypeToken>() {}.type - val arr = gson.fromJson>(arrJson, listType) - val seen = mutableSetOf() - val ordered = mutableListOf() - arr.forEach { raw -> + val arr = JsonUtil.fromJsonOrNull(ListSerializer(String.serializer()), arrJson) + if (arr != null) { + val seen = mutableSetOf() + val ordered = mutableListOf() + arr.forEach { raw -> val gh = normalize(raw) if (gh.isNotEmpty() && !seen.contains(gh)) { seen.add(gh) ordered.add(gh) } + } + membership.clear(); membership.addAll(seen) + _bookmarks.postValue(ordered) } - membership.clear(); membership.addAll(seen) - _bookmarks.postValue(ordered) } } catch (e: Exception) { Log.e(TAG, "Failed to load bookmarks: ${e.message}") @@ -116,9 +115,10 @@ class GeohashBookmarksStore private constructor(private val context: Context) { try { val namesJson = prefs.getString(NAMES_STORE_KEY, null) if (!namesJson.isNullOrEmpty()) { - val mapType = object : TypeToken>() {}.type - val dict = gson.fromJson>(namesJson, mapType) - _bookmarkNames.postValue(dict) + val dict = JsonUtil.fromJsonOrNull(MapSerializer(String.serializer(), String.serializer()), namesJson) + if (dict != null) { + _bookmarkNames.postValue(dict) + } } } catch (e: Exception) { Log.e(TAG, "Failed to load bookmark names: ${e.message}") @@ -127,14 +127,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) { private fun persist() { try { - val json = gson.toJson(_bookmarks.value ?: emptyList()) + val json = JsonUtil.toJson(ListSerializer(String.serializer()), _bookmarks.value ?: emptyList()) prefs.edit().putString(STORE_KEY, json).apply() } catch (_: Exception) {} } private fun persistNames() { try { - val json = gson.toJson(_bookmarkNames.value ?: emptyMap()) + val json = JsonUtil.toJson(MapSerializer(String.serializer(), String.serializer()), _bookmarkNames.value ?: emptyMap()) prefs.edit().putString(NAMES_STORE_KEY, json).apply() } catch (_: Exception) {} } @@ -235,14 +235,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) { private fun persist(list: List) { try { - val json = gson.toJson(list) + val json = JsonUtil.toJson(ListSerializer(String.serializer()), list) prefs.edit().putString(STORE_KEY, json).apply() } catch (_: Exception) {} } private fun persistNames(map: Map) { try { - val json = gson.toJson(map) + val json = JsonUtil.toJson(MapSerializer(String.serializer(), String.serializer()), map) prefs.edit().putString(NAMES_STORE_KEY, json).apply() } catch (_: Exception) {} } diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt index b8da1fee3..0b34b8df1 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -14,26 +14,24 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.* import java.util.* -import com.google.gson.Gson +import kotlinx.serialization.json.* +import com.bitchat.android.util.JsonUtil import com.google.gson.JsonSyntaxException +import jakarta.inject.Inject +import jakarta.inject.Singleton /** * Manages location permissions, one-shot location retrieval, and computing geohash channels. * Direct port from iOS LocationChannelManager for 100% compatibility */ -class LocationChannelManager private constructor(private val context: Context) { +@Singleton +class LocationChannelManager @Inject constructor( + private val context: Context, + private val dataManager: com.bitchat.android.ui.DataManager +) { companion object { private const val TAG = "LocationChannelManager" - - @Volatile - private var INSTANCE: LocationChannelManager? = null - - fun getInstance(context: Context): LocationChannelManager { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: LocationChannelManager(context.applicationContext).also { INSTANCE = it } - } - } } // State enum matching iOS @@ -49,8 +47,7 @@ class LocationChannelManager private constructor(private val context: Context) { private var lastLocation: Location? = null private var refreshTimer: Job? = null private var isGeocoding: Boolean = false - private val gson = Gson() - private var dataManager: com.bitchat.android.ui.DataManager? = null + // Published state for UI bindings (matching iOS @Published properties) private val _permissionState = MutableLiveData(PermissionState.NOT_DETERMINED) @@ -78,8 +75,6 @@ class LocationChannelManager private constructor(private val context: Context) { init { updatePermissionState() - // Initialize DataManager and load persisted settings - dataManager = com.bitchat.android.ui.DataManager(context) loadPersistedChannelSelection() loadLocationServicesState() } @@ -544,10 +539,10 @@ class LocationChannelManager private constructor(private val context: Context) { try { val channelData = when (channel) { is ChannelID.Mesh -> { - gson.toJson(mapOf("type" to "mesh")) + JsonUtil.toJson(mapOf("type" to "mesh")) } is ChannelID.Location -> { - gson.toJson(mapOf( + JsonUtil.toJson(mapOf( "type" to "location", "level" to channel.channel.level.name, "precision" to channel.channel.level.precision, @@ -556,7 +551,7 @@ class LocationChannelManager private constructor(private val context: Context) { )) } } - dataManager?.saveLastGeohashChannel(channelData) + dataManager.saveLastGeohashChannel(channelData) Log.d(TAG, "Saved channel selection: ${channel.displayName}") } catch (e: Exception) { Log.e(TAG, "Failed to save channel selection: ${e.message}") @@ -568,9 +563,16 @@ class LocationChannelManager private constructor(private val context: Context) { */ private fun loadPersistedChannelSelection() { try { - val channelData = dataManager?.loadLastGeohashChannel() + val channelData = dataManager.loadLastGeohashChannel() if (channelData != null) { - val channelMap = gson.fromJson(channelData, Map::class.java) as? Map + val channelMap = try { + JsonUtil.json.parseToJsonElement(channelData).jsonObject.mapValues { + when (val value = it.value) { + is JsonPrimitive -> if (value.isString) value.content else value.toString() + else -> value.toString() + } + } + } catch (e: Exception) { null } if (channelMap != null) { val channel = when (channelMap["type"] as? String) { "mesh" -> ChannelID.Mesh @@ -628,7 +630,7 @@ class LocationChannelManager private constructor(private val context: Context) { * Clear persisted channel selection (useful for testing or reset) */ fun clearPersistedChannel() { - dataManager?.clearLastGeohashChannel() + dataManager.clearLastGeohashChannel() _selectedChannel.postValue(ChannelID.Mesh) Log.d(TAG, "Cleared persisted channel selection") } @@ -640,7 +642,7 @@ class LocationChannelManager private constructor(private val context: Context) { */ private fun saveLocationServicesState(enabled: Boolean) { try { - dataManager?.saveLocationServicesEnabled(enabled) + dataManager.saveLocationServicesEnabled(enabled) Log.d(TAG, "Saved location services state: $enabled") } catch (e: Exception) { Log.e(TAG, "Failed to save location services state: ${e.message}") @@ -652,7 +654,7 @@ class LocationChannelManager private constructor(private val context: Context) { */ private fun loadLocationServicesState() { try { - val enabled = dataManager?.isLocationServicesEnabled() ?: false + val enabled = dataManager.isLocationServicesEnabled() _locationServicesEnabled.postValue(enabled) Log.d(TAG, "Loaded location services state: $enabled") } catch (e: 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 2b0b2bddf..5683268de 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -6,6 +6,8 @@ import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import java.security.MessageDigest import android.util.Log +import jakarta.inject.Inject +import jakarta.inject.Singleton /** * Manages persistent identity storage and peer ID rotation - 100% compatible with iOS implementation @@ -15,7 +17,8 @@ import android.util.Log * - Secure storage using Android EncryptedSharedPreferences * - Fingerprint calculation and identity validation */ -class SecureIdentityStateManager(private val context: Context) { +@Singleton +class SecureIdentityStateManager @Inject constructor(private val context: Context) { companion object { private const val TAG = "SecureIdentityStateManager" diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index f446888e6..7825d1953 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -16,7 +16,8 @@ import kotlinx.coroutines.flow.collect class BluetoothConnectionManager( private val context: Context, private val myPeerID: String, - private val fragmentManager: FragmentManager? = null + private val fragmentManager: FragmentManager? = null, + private val debugManager: com.bitchat.android.ui.debug.DebugSettingsManager ) : PowerManagerDelegate { companion object { @@ -36,8 +37,8 @@ class BluetoothConnectionManager( // Component managers private val permissionManager = BluetoothPermissionManager(context) - private val connectionTracker = BluetoothConnectionTracker(connectionScope, powerManager) - private val packetBroadcaster = BluetoothPacketBroadcaster(connectionScope, connectionTracker, fragmentManager) + private val connectionTracker = BluetoothConnectionTracker(connectionScope, powerManager, debugManager) + private val packetBroadcaster = BluetoothPacketBroadcaster(connectionScope, connectionTracker, fragmentManager, debugManager) // Delegate for component managers to call back to main manager private val componentDelegate = object : BluetoothConnectionManagerDelegate { @@ -70,10 +71,10 @@ class BluetoothConnectionManager( } private val serverManager = BluetoothGattServerManager( - context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate + context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate, debugManager ) private val clientManager = BluetoothGattClientManager( - context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate + context, connectionScope, connectionTracker, permissionManager, powerManager, componentDelegate, debugManager ) // Service state @@ -89,39 +90,38 @@ class BluetoothConnectionManager( powerManager.delegate = this // Observe debug settings to enforce role state while active try { - val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() // Role enable/disable connectionScope.launch { - dbg.gattServerEnabled.collect { enabled -> + debugManager.gattServerEnabled.collect { enabled -> if (!isActive) return@collect if (enabled) startServer() else stopServer() } } connectionScope.launch { - dbg.gattClientEnabled.collect { enabled -> + debugManager.gattClientEnabled.collect { enabled -> if (!isActive) return@collect if (enabled) startClient() else stopClient() } } // Connection caps: enforce on change connectionScope.launch { - dbg.maxConnectionsOverall.collect { + debugManager.maxConnectionsOverall.collect { if (!isActive) return@collect connectionTracker.enforceConnectionLimits() // Also enforce server side best-effort - serverManager.enforceServerLimit(dbg.maxServerConnections.value) + serverManager.enforceServerLimit(debugManager.maxServerConnections.value) } } connectionScope.launch { - dbg.maxClientConnections.collect { + debugManager.maxClientConnections.collect { if (!isActive) return@collect connectionTracker.enforceConnectionLimits() } } connectionScope.launch { - dbg.maxServerConnections.collect { + debugManager.maxServerConnections.collect { if (!isActive) return@collect - serverManager.enforceServerLimit(dbg.maxServerConnections.value) + serverManager.enforceServerLimit(debugManager.maxServerConnections.value) } } } catch (_: Exception) { } @@ -165,9 +165,8 @@ class BluetoothConnectionManager( powerManager.start() // Start server/client based on debug settings - val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null } - val startServer = dbg?.gattServerEnabled?.value != false - val startClient = dbg?.gattClientEnabled?.value != false + val startServer = debugManager.gattServerEnabled.value + val startClient = debugManager.gattClientEnabled.value if (startServer) { if (!serverManager.start()) { @@ -350,7 +349,7 @@ class BluetoothConnectionManager( val wasUsingDutyCycle = powerManager.shouldUseDutyCycle() // Update advertising with new power settings if server enabled - val serverEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true } + val serverEnabled = debugManager.gattServerEnabled.value if (serverEnabled) { serverManager.restartAdvertising() } else { @@ -361,7 +360,7 @@ class BluetoothConnectionManager( val nowUsingDutyCycle = powerManager.shouldUseDutyCycle() if (wasUsingDutyCycle != nowUsingDutyCycle) { Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan") - val clientEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val clientEnabled = debugManager.gattClientEnabled.value if (clientEnabled) { clientManager.restartScanning() } else { @@ -375,7 +374,7 @@ class BluetoothConnectionManager( connectionTracker.enforceConnectionLimits() // Best-effort server cap try { - val maxServer = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().maxServerConnections.value + val maxServer = debugManager.maxServerConnections.value serverManager.enforceServerLimit(maxServer) } catch (_: Exception) { } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index 0f0bdd860..448724772 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -4,6 +4,7 @@ import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCharacteristic import android.util.Log +import com.bitchat.android.ui.debug.DebugSettingsManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -15,7 +16,8 @@ import java.util.concurrent.CopyOnWriteArrayList */ class BluetoothConnectionTracker( private val connectionScope: CoroutineScope, - private val powerManager: PowerManager + private val powerManager: PowerManager, + private val debugManager: DebugSettingsManager ) { companion object { @@ -236,10 +238,9 @@ class BluetoothConnectionTracker( */ fun enforceConnectionLimits() { // Read debug overrides if available - val dbg = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (_: Exception) { null } - val maxOverall = dbg?.maxConnectionsOverall?.value ?: powerManager.getMaxConnections() - val maxClient = dbg?.maxClientConnections?.value ?: maxOverall - val maxServer = dbg?.maxServerConnections?.value ?: maxOverall + val maxOverall = debugManager.maxConnectionsOverall.value + val maxClient = debugManager.maxClientConnections.value + val maxServer = debugManager.maxServerConnections.value val clients = connectedDevices.values.filter { it.isClient } val servers = connectedDevices.values.filter { !it.isClient } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index e5feea0a0..17c2675c0 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -27,7 +27,8 @@ class BluetoothGattClientManager( private val connectionTracker: BluetoothConnectionTracker, private val permissionManager: BluetoothPermissionManager, private val powerManager: PowerManager, - private val delegate: BluetoothConnectionManagerDelegate? + private val delegate: BluetoothConnectionManagerDelegate?, + private val debugManager: DebugSettingsManager ) { companion object { @@ -76,7 +77,7 @@ class BluetoothGattClientManager( fun start(): Boolean { // Respect debug setting try { - if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value) { + if (!debugManager.gattClientEnabled.value) { Log.i(TAG, "Client start skipped: GATT Client disabled in debug settings") return false } @@ -150,7 +151,7 @@ class BluetoothGattClientManager( * Handle scan state changes from power manager */ fun onScanStateChanged(shouldScan: Boolean) { - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val enabled = try { debugManager.gattClientEnabled.value } catch (_: Exception) { true } if (shouldScan && enabled) { startScanning() } else { @@ -199,7 +200,7 @@ class BluetoothGattClientManager( @Suppress("DEPRECATION") private fun startScanning() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val enabled = try { debugManager.gattClientEnabled.value } catch (_: Exception) { true } if (!permissionManager.hasBluetoothPermissions() || bleScanner == null || !isActive || !enabled) return // Rate limit scan starts to prevent "scanning too frequently" errors @@ -327,7 +328,7 @@ class BluetoothGattClientManager( // Publish scan result to debug UI buffer try { - DebugSettingsManager.getInstance().addScanResult( + debugManager.addScanResult( DebugScanResult( deviceName = device.name, deviceAddress = deviceAddress, @@ -343,7 +344,7 @@ class BluetoothGattClientManager( // Even if we skip connecting, still publish scan result to debug UI try { val pid: String? = null // We don't know peerID until packet exchange - DebugSettingsManager.getInstance().addScanResult( + debugManager.addScanResult( DebugScanResult( deviceName = device.name, deviceAddress = deviceAddress, @@ -541,7 +542,7 @@ class BluetoothGattClientManager( */ fun restartScanning() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true } + val enabled = try { debugManager.gattClientEnabled.value } catch (_: Exception) { true } if (!isActive || !enabled) return connectionScope.launch { diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt index 6a1c6fbf3..a4874dfdd 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt @@ -9,6 +9,7 @@ import android.content.Context import android.os.ParcelUuid import android.util.Log import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.ui.debug.DebugSettingsManager import com.bitchat.android.util.AppConstants import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay @@ -24,7 +25,8 @@ class BluetoothGattServerManager( private val connectionTracker: BluetoothConnectionTracker, private val permissionManager: BluetoothPermissionManager, private val powerManager: PowerManager, - private val delegate: BluetoothConnectionManagerDelegate? + private val delegate: BluetoothConnectionManagerDelegate?, + private val debugManager: DebugSettingsManager ) { companion object { @@ -65,7 +67,7 @@ class BluetoothGattServerManager( fun start(): Boolean { // Respect debug setting try { - if (!com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value) { + if (!debugManager.gattServerEnabled.value) { Log.i(TAG, "Server start skipped: GATT Server disabled in debug settings") return false } @@ -323,7 +325,7 @@ class BluetoothGattServerManager( @Suppress("DEPRECATION") private fun startAdvertising() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true } + val enabled = try { debugManager.gattServerEnabled.value } catch (_: Exception) { true } // Guard conditions – never throw here to avoid crashing the app from a background coroutine if (!permissionManager.hasBluetoothPermissions()) { @@ -399,7 +401,7 @@ class BluetoothGattServerManager( */ fun restartAdvertising() { // Respect debug setting - val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true } + val enabled = try { debugManager.gattServerEnabled.value } catch (_: Exception) { true } if (!isActive || !enabled) { stopAdvertising() return diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 56c640dfd..6f5451a9b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -3,6 +3,7 @@ package com.bitchat.android.mesh import android.content.Context import android.util.Log import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.protocol.MessagePadding import com.bitchat.android.model.RoutedPacket @@ -12,11 +13,14 @@ import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.model.RequestSyncPacket import com.bitchat.android.sync.GossipSyncManager +import com.bitchat.android.ui.debug.DebugPreferenceManager import com.bitchat.android.util.toHexString import kotlinx.coroutines.* import java.util.* import kotlin.math.sign import kotlin.random.Random +import jakarta.inject.Singleton +import jakarta.inject.Inject /** * Bluetooth mesh service - REFACTORED to use component-based architecture @@ -31,75 +35,79 @@ import kotlin.random.Random * - BluetoothConnectionManager: BLE connections and GATT operations * - PacketProcessor: Incoming packet routing */ -class BluetoothMeshService(private val context: Context) { - private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } +@Singleton +class BluetoothMeshService @Inject constructor( + private val context: Context, + private val fingerprintManager: PeerFingerprintManager, + private val encryptionService: EncryptionService, + private val debugManager: com.bitchat.android.ui.debug.DebugSettingsManager, + private val debugPreferenceManager: DebugPreferenceManager, + private val favoritesService: FavoritesPersistenceService +) { companion object { private const val TAG = "BluetoothMeshService" private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS } - - // Core components - each handling specific responsibilities - private val encryptionService = EncryptionService(context) // My peer identification - derived from persisted Noise identity fingerprint (first 16 hex chars) val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) - private val peerManager = PeerManager() + private val peerManager = PeerManager(fingerprintManager) private val fragmentManager = FragmentManager() private val securityManager = SecurityManager(encryptionService, myPeerID) private val storeForwardManager = StoreForwardManager() - private val messageHandler = MessageHandler(myPeerID, context.applicationContext) - internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access - private val packetProcessor = PacketProcessor(myPeerID) - private lateinit var gossipSyncManager: GossipSyncManager - - // Service state management - private var isActive = false - - // Delegate for message callbacks (maintains same interface) - var delegate: BluetoothMeshDelegate? = null - - // Coroutines - private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val messageHandler = MessageHandler(myPeerID, context.applicationContext, favoritesService) + internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager, debugManager) // Made internal for access + private val packetProcessor = PacketProcessor(myPeerID, debugManager) - init { - setupDelegates() - messageHandler.packetProcessor = packetProcessor - //startPeriodicDebugLogging() - - // Initialize sync manager (needs serviceScope) - gossipSyncManager = GossipSyncManager( + private val gossipSyncManager: GossipSyncManager by lazy { + GossipSyncManager( myPeerID = myPeerID, scope = serviceScope, configProvider = object : GossipSyncManager.ConfigProvider { override fun seenCapacity(): Int = try { - com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(500) + debugPreferenceManager.getSeenPacketCapacity(500) } catch (_: Exception) { 500 } override fun gcsMaxBytes(): Int = try { - com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsMaxFilterBytes(400) + debugPreferenceManager.getGcsMaxFilterBytes(400) } catch (_: Exception) { 400 } override fun gcsTargetFpr(): Double = try { - com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsFprPercent(1.0) / 100.0 + debugPreferenceManager.getGcsFprPercent(1.0) / 100.0 } catch (_: Exception) { 0.01 } } - ) - - // Wire sync manager delegate - gossipSyncManager.delegate = object : GossipSyncManager.Delegate { - override fun sendPacket(packet: BitchatPacket) { - connectionManager.broadcastPacket(RoutedPacket(packet)) - } - override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { - connectionManager.sendPacketToPeer(peerID, packet) - } - override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { - return signPacketBeforeBroadcast(packet) + ).also { syncManager -> + // Wire sync manager delegate + syncManager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) { + connectionManager.broadcastPacket(RoutedPacket(packet)) + } + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { + connectionManager.sendPacketToPeer(peerID, packet) + } + override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket { + return signPacketBeforeBroadcast(packet) + } } } } + // Service state management + private var isActive = false + + // Delegate for message callbacks (maintains same interface) + var delegate: BluetoothMeshDelegate? = null + + // Coroutines + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + init { + setupDelegates() + messageHandler.packetProcessor = packetProcessor + //startPeriodicDebugLogging() + } + /** * Start periodic debug logging every 10 seconds */ @@ -332,8 +340,8 @@ class BluetoothMeshService(private val context: Context) { // Index existing Nostr mapping by the new peerID if we have it try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub -> - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub) + favoritesService.findNostrPubkey(publicKey)?.let { npub -> + favoritesService.updateNostrPublicKeyForPeerID(newPeerID, npub) } } catch (_: Exception) { } @@ -499,8 +507,7 @@ class BluetoothMeshService(private val context: Context) { val addr = device.address val peer = connectionManager.addressPeerMap[addr] val nick = peer?.let { peerManager.getPeerNickname(it) } ?: "unknown" - com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() - .logPeerConnection(peer ?: "unknown", nick, addr, isInbound = !connectionManager.isClientConnection(addr)!!) + debugManager.logPeerConnection(peer ?: "unknown", nick, addr, isInbound = !connectionManager.isClientConnection(addr)!!) } catch (_: Exception) { } } @@ -519,8 +526,7 @@ class BluetoothMeshService(private val context: Context) { // Verbose debug: device disconnected try { val nick = peerManager.getPeerNickname(peer) ?: "unknown" - com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() - .logPeerDisconnection(peer, nick, addr) + debugManager.logPeerDisconnection(peer, nick, addr) } catch (_: Exception) { } } } @@ -811,7 +817,11 @@ class BluetoothMeshService(private val context: Context) { Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID") // Route geohash read receipts via MessageRouter instead of here - val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull() + // Break circular dependency by retrieving MessageRouter lazily from Koin + val geo = try { + org.koin.java.KoinJavaComponent.getKoin().get() + } catch (e: Exception) { null } + val isGeoAlias = try { val map = com.bitchat.android.nostr.GeohashAliasRegistry.snapshot() map.containsKey(recipientPeerID) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index b34742177..f785d6747 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -9,6 +9,7 @@ import android.util.Log import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.MessageType +import com.bitchat.android.ui.debug.DebugSettingsManager import com.bitchat.android.util.toHexString import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -42,7 +43,8 @@ import kotlinx.coroutines.channels.actor class BluetoothPacketBroadcaster( private val connectionScope: CoroutineScope, private val connectionTracker: BluetoothConnectionTracker, - private val fragmentManager: FragmentManager? + private val fragmentManager: FragmentManager?, + private val debugManager: DebugSettingsManager ) { companion object { @@ -75,7 +77,7 @@ class BluetoothPacketBroadcaster( val toNick = toPeer?.let { nicknameResolver?.invoke(it) } val isRelay = (incomingAddr != null || incomingPeer != null) - com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logPacketRelayDetailed( + debugManager.logPacketRelayDetailed( packetType = typeName, senderPeerID = senderPeerID, senderNickname = senderNick, diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index d016dd37b..ceb1da05f 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -1,6 +1,7 @@ package com.bitchat.android.mesh import android.util.Log +import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.model.IdentityAnnouncement @@ -16,7 +17,11 @@ import kotlin.random.Random * Handles processing of different message types * Extracted from BluetoothMeshService for better separation of concerns */ -class MessageHandler(private val myPeerID: String, private val appContext: android.content.Context) { +class MessageHandler( + private val myPeerID: String, + private val appContext: android.content.Context, + private val favoritesService: FavoritesPersistenceService +) { companion object { private const val TAG = "MessageHandler" @@ -536,15 +541,15 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro val peerInfo = delegate?.getPeerInfo(fromPeerID) val noiseKey = peerInfo?.noisePublicKey if (noiseKey != null) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite) + favoritesService.updatePeerFavoritedUs(noiseKey, isFavorite) if (npub != null) { // Index by noise key and current mesh peerID for fast Nostr routing - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub) - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(fromPeerID, npub) + favoritesService.updateNostrPublicKey(noiseKey, npub) + favoritesService.updateNostrPublicKeyForPeerID(fromPeerID, npub) } // Determine iOS-style guidance text - val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) + val rel = favoritesService.getFavoriteStatus(noiseKey) val guidance = if (isFavorite) { if (rel?.isFavorite == true) { " — mutual! You can continue DMs via Nostr when out of mesh." diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index 2b5fac102..be8ef589b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -4,6 +4,7 @@ import android.util.Log import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.ui.debug.DebugSettingsManager import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.actor @@ -15,8 +16,10 @@ import kotlinx.coroutines.channels.actor * Prevents race condition where multiple threads process packets * from the same peer simultaneously, causing session management conflicts. */ -class PacketProcessor(private val myPeerID: String) { - private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } +class PacketProcessor( + private val myPeerID: String, + private val debugManager: DebugSettingsManager +) { companion object { private const val TAG = "PacketProcessor" @@ -32,7 +35,7 @@ class PacketProcessor(private val myPeerID: String) { } // Packet relay manager for centralized relay decisions - private val packetRelayManager = PacketRelayManager(myPeerID) + private val packetRelayManager = PacketRelayManager(myPeerID, debugManager) // Coroutines private val processorScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -136,7 +139,7 @@ class PacketProcessor(private val myPeerID: String) { val mt = messageType?.name ?: packet.type.toString() val routeDevice = routed.relayAddress val nick = delegate?.getPeerNickname(peerID) - debugManager?.logIncomingPacket(peerID, nick, mt, routeDevice) + debugManager.logIncomingPacket(peerID, nick, mt, routeDevice) } catch (_: Exception) { } diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt index bc401daac..4348c0358 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketRelayManager.kt @@ -1,11 +1,15 @@ package com.bitchat.android.mesh -import com.bitchat.android.protocol.MessageType import android.util.Log import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.ui.debug.DebugSettingsManager import com.bitchat.android.util.toHexString -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive import kotlin.random.Random /** @@ -14,15 +18,17 @@ import kotlin.random.Random * This class handles all relay decisions and logic for bitchat packets. * All packets that aren't specifically addressed to us get processed here. */ -class PacketRelayManager(private val myPeerID: String) { - private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } +class PacketRelayManager( + private val myPeerID: String, + private val debugManager: DebugSettingsManager +) { companion object { private const val TAG = "PacketRelayManager" } private fun isRelayEnabled(): Boolean = try { - com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().packetRelayEnabled.value + debugManager.packetRelayEnabled.value } catch (_: Exception) { true } // Logging moved to BluetoothPacketBroadcaster per actual transmission target diff --git a/app/src/main/java/com/bitchat/android/mesh/PeerFingerprintManager.kt b/app/src/main/java/com/bitchat/android/mesh/PeerFingerprintManager.kt index c48b5bd94..8a42bf395 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PeerFingerprintManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PeerFingerprintManager.kt @@ -3,6 +3,8 @@ package com.bitchat.android.mesh import android.util.Log import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap +import jakarta.inject.Inject +import jakarta.inject.Singleton /** * Centralized peer fingerprint management singleton @@ -19,22 +21,10 @@ import java.util.concurrent.ConcurrentHashMap * - Support for peer ID rotation while maintaining persistent identity * - Centralized logging for debugging identity management */ -class PeerFingerprintManager private constructor() { - +@Singleton +class PeerFingerprintManager @Inject constructor() { companion object { private const val TAG = "PeerFingerprintManager" - - @Volatile - private var INSTANCE: PeerFingerprintManager? = null - - /** - * Get the singleton instance - */ - fun getInstance(): PeerFingerprintManager { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: PeerFingerprintManager().also { INSTANCE = it } - } - } } // Bidirectional mapping for efficient lookups diff --git a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt index 4c279d4ad..f1f50c4d9 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt @@ -63,7 +63,9 @@ data class PeerInfo( * Now includes centralized peer fingerprint management via PeerFingerprintManager singleton * and support for signed announcement verification */ -class PeerManager { +class PeerManager( + private val fingerprintManager: PeerFingerprintManager +) { companion object { private const val TAG = "PeerManager" @@ -79,10 +81,7 @@ class PeerManager { private val announcedToPeers = CopyOnWriteArrayList() // Legacy fields removed: use PeerInfo map exclusively - - // Centralized fingerprint management - private val fingerprintManager = PeerFingerprintManager.getInstance() - + // Delegate for callbacks var delegate: PeerManagerDelegate? = null diff --git a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt index 8e1731b1a..895c2fb5c 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt @@ -1,14 +1,14 @@ package com.bitchat.android.model -import android.os.Parcelable -import com.google.gson.GsonBuilder -import kotlinx.parcelize.Parcelize +import com.bitchat.android.util.DateSerializer +import com.bitchat.android.util.ByteArraySerializer +import kotlinx.serialization.Serializable import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.* -@Parcelize -enum class BitchatMessageType : Parcelable { +@Serializable +enum class BitchatMessageType { Message, Audio, Image, @@ -18,23 +18,24 @@ enum class BitchatMessageType : Parcelable { /** * Delivery status for messages - exact same as iOS version */ -sealed class DeliveryStatus : Parcelable { - @Parcelize +@Serializable +sealed class DeliveryStatus { + @Serializable object Sending : DeliveryStatus() - @Parcelize + @Serializable object Sent : DeliveryStatus() - @Parcelize - data class Delivered(val to: String, val at: Date) : DeliveryStatus() + @Serializable + data class Delivered(val to: String, @Serializable(with = DateSerializer::class) val at: Date) : DeliveryStatus() - @Parcelize - data class Read(val by: String, val at: Date) : DeliveryStatus() + @Serializable + data class Read(val by: String, @Serializable(with = DateSerializer::class) val at: Date) : DeliveryStatus() - @Parcelize + @Serializable data class Failed(val reason: String) : DeliveryStatus() - @Parcelize + @Serializable data class PartiallyDelivered(val reached: Int, val total: Int) : DeliveryStatus() fun getDisplayText(): String { @@ -52,13 +53,13 @@ sealed class DeliveryStatus : Parcelable { /** * BitchatMessage - 100% compatible with iOS version */ -@Parcelize +@Serializable data class BitchatMessage( val id: String = UUID.randomUUID().toString().uppercase(), val sender: String, val content: String, val type: BitchatMessageType = BitchatMessageType.Message, - val timestamp: Date, + @Serializable(with = DateSerializer::class) val timestamp: Date, val isRelay: Boolean = false, val originalSender: String? = null, val isPrivate: Boolean = false, @@ -66,11 +67,11 @@ data class BitchatMessage( val senderPeerID: String? = null, val mentions: List? = null, val channel: String? = null, - val encryptedContent: ByteArray? = null, + @Serializable(with = ByteArraySerializer::class) val encryptedContent: ByteArray? = null, val isEncrypted: Boolean = false, val deliveryStatus: DeliveryStatus? = null, val powDifficulty: Int? = null -) : Parcelable { +) { /** * Convert message to binary payload format - exactly same as iOS version diff --git a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt index 2dfbe9c23..f47056ea7 100644 --- a/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt +++ b/app/src/main/java/com/bitchat/android/model/IdentityAnnouncement.kt @@ -1,19 +1,18 @@ package com.bitchat.android.model -import android.os.Parcelable -import kotlinx.parcelize.Parcelize -import com.bitchat.android.util.* +import kotlinx.serialization.Serializable +import com.bitchat.android.util.ByteArraySerializer /** * Identity announcement structure with TLV encoding * Compatible with iOS AnnouncementPacket TLV format */ -@Parcelize +@Serializable data class IdentityAnnouncement( val nickname: String, - val noisePublicKey: ByteArray, // Noise static public key (Curve25519.KeyAgreement) - val signingPublicKey: ByteArray // Ed25519 public key for signing -) : Parcelable { + @Serializable(with = ByteArraySerializer::class) val noisePublicKey: ByteArray, // Noise static public key (Curve25519.KeyAgreement) + @Serializable(with = ByteArraySerializer::class) val signingPublicKey: ByteArray // Ed25519 public key for signing +) { /** * TLV types matching iOS implementation diff --git a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt index 7f691a9cc..55367ecfd 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -1,7 +1,8 @@ package com.bitchat.android.model -import android.os.Parcelable -import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import com.bitchat.android.util.ByteArraySerializer + /** * Noise encrypted payload types and handling - 100% compatible with iOS SimplifiedBluetoothService @@ -35,11 +36,11 @@ enum class NoisePayloadType(val value: UByte) { * Helper class for creating and parsing Noise payloads * Matches iOS NoisePayload helper exactly */ -@Parcelize +@Serializable data class NoisePayload( val type: NoisePayloadType, - val data: ByteArray -) : Parcelable { + @Serializable(with = ByteArraySerializer::class) val data: ByteArray +) { /** * Encode payload with type prefix - exactly like iOS @@ -97,11 +98,11 @@ data class NoisePayload( /** * Private message packet with TLV encoding - matches iOS PrivateMessagePacket exactly */ -@Parcelize +@Serializable data class PrivateMessagePacket( val messageID: String, val content: String -) : Parcelable { +) { /** * TLV types matching iOS implementation exactly @@ -197,8 +198,8 @@ data class PrivateMessagePacket( /** * Read receipt data class for transport compatibility */ -@Parcelize +@Serializable data class ReadReceipt( val originalMessageID: String, val readerPeerID: String? = null -) : Parcelable +) diff --git a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt index 45cff7734..19a7058ee 100644 --- a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt +++ b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt @@ -1,5 +1,8 @@ package com.bitchat.android.net +import jakarta.inject.Inject +import jakarta.inject.Singleton +import kotlinx.coroutines.launch import okhttp3.OkHttpClient import java.net.InetSocketAddress import java.net.Proxy @@ -9,9 +12,23 @@ import java.util.concurrent.atomic.AtomicReference /** * Centralized OkHttp provider to ensure all network traffic honors Tor settings. */ -object OkHttpProvider { +@Singleton +class OkHttpProvider @Inject constructor( + private val torManager: TorManager +) { private val httpClientRef = AtomicReference(null) private val wsClientRef = AtomicReference(null) + private val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO + kotlinx.coroutines.SupervisorJob()) + + init { + scope.launch { + torManager.statusFlow.collect { + // Reset clients whenever Tor status changes to ensure we pick up new proxy settings + reset() + } + } + } + fun reset() { httpClientRef.set(null) @@ -42,7 +59,7 @@ object OkHttpProvider { private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder { val builder = OkHttpClient.Builder() - val socks: InetSocketAddress? = TorManager.currentSocksAddress() + val socks: InetSocketAddress? = torManager.currentSocksAddress() // If a SOCKS address is defined, always use it. TorManager sets this as soon as Tor mode is ON, // even before bootstrap, to prevent any direct connections from occurring. if (socks != null) { diff --git a/app/src/main/java/com/bitchat/android/net/TorManager.kt b/app/src/main/java/com/bitchat/android/net/TorManager.kt index b7c2230df..0101b4a0e 100644 --- a/app/src/main/java/com/bitchat/android/net/TorManager.kt +++ b/app/src/main/java/com/bitchat/android/net/TorManager.kt @@ -4,6 +4,8 @@ import android.app.Application import android.util.Log import info.guardianproject.arti.ArtiLogListener import info.guardianproject.arti.ArtiProxy +import jakarta.inject.Inject +import jakarta.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -27,13 +29,19 @@ import java.util.concurrent.atomic.AtomicLong * Manages embedded Tor lifecycle & provides SOCKS proxy address. * Uses Arti (Tor in Rust) for improved security and reliability. */ -object TorManager { - private const val TAG = "TorManager" - private const val DEFAULT_SOCKS_PORT = com.bitchat.android.util.AppConstants.Tor.DEFAULT_SOCKS_PORT - private const val RESTART_DELAY_MS = com.bitchat.android.util.AppConstants.Tor.RESTART_DELAY_MS // 2 seconds between stop/start - private const val INACTIVITY_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.INACTIVITY_TIMEOUT_MS // 5 seconds of no activity before restart - private const val MAX_RETRY_ATTEMPTS = com.bitchat.android.util.AppConstants.Tor.MAX_RETRY_ATTEMPTS - private const val STOP_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.STOP_TIMEOUT_MS +@Singleton +class TorManager @Inject constructor( + private val application: Application +) { + + companion object{ + private const val TAG = "TorManager" + private const val DEFAULT_SOCKS_PORT = com.bitchat.android.util.AppConstants.Tor.DEFAULT_SOCKS_PORT + private const val RESTART_DELAY_MS = com.bitchat.android.util.AppConstants.Tor.RESTART_DELAY_MS // 2 seconds between stop/start + private const val INACTIVITY_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.INACTIVITY_TIMEOUT_MS // 5 seconds of no activity before restart + private const val MAX_RETRY_ATTEMPTS = com.bitchat.android.util.AppConstants.Tor.MAX_RETRY_ATTEMPTS + private const val STOP_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.STOP_TIMEOUT_MS + } private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -49,7 +57,6 @@ object TorManager { @Volatile private var bindRetryAttempts = 0 private var inactivityJob: Job? = null private var retryJob: Job? = null - private var currentApplication: Application? = null private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING } @Volatile private var lifecycleState: LifecycleState = LifecycleState.STOPPED @@ -74,12 +81,16 @@ object TorManager { return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null && s.state == TorState.RUNNING } - fun init(application: Application) { + init { + initialize() + } + + private fun initialize() { if (initialized) return synchronized(this) { if (initialized) return initialized = true - currentApplication = application + // currentApplication = application TorPreferenceManager.init(application) // Apply saved mode at startup. If ON, set planned SOCKS immediately to avoid any leak. @@ -90,7 +101,7 @@ object TorManager { } desiredMode = savedMode socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) - try { OkHttpProvider.reset() } catch (_: Throwable) { } + // OkHttpProvider reset handled by observation } appScope.launch { applyMode(application, savedMode) @@ -130,11 +141,6 @@ object TorManager { currentSocksPort = DEFAULT_SOCKS_PORT bindRetryAttempts = 0 lifecycleState = LifecycleState.STOPPED - // Rebuild clients WITHOUT proxy and reconnect relays - try { - OkHttpProvider.reset() - com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() - } catch (_: Throwable) { } } TorMode.ON -> { Log.i(TAG, "applyMode: ON -> starting arti") @@ -148,8 +154,6 @@ object TorManager { // Immediately set the planned SOCKS address so all traffic is forced through it, // even before Tor is fully bootstrapped. This prevents any direct connections. socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) - try { OkHttpProvider.reset() } catch (_: Throwable) { } - try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { } startArti(application, useDelay = false) // Defer enabling proxy until bootstrap completes appScope.launch { @@ -157,8 +161,6 @@ object TorManager { if (_status.value.running && desiredMode == TorMode.ON) { socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) Log.i(TAG, "Tor ON: proxy set to ${socksAddr}") - OkHttpProvider.reset() - try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { } } } } @@ -207,7 +209,7 @@ object TorManager { } catch (e: Exception) { Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}") _status.value = _status.value.copy(state = TorState.ERROR) - + // Check if this is a bind error val isBindError = isBindError(e) if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) { @@ -216,8 +218,6 @@ object TorManager { Log.w(TAG, "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort") // Update planned SOCKS address immediately so all new connections target the new port socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) - try { OkHttpProvider.reset() } catch (_: Throwable) { } - try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { } // Immediate retry with incremented port, no exponential backoff for bind errors startArti(application, useDelay = false) } else if (isBindError) { @@ -287,18 +287,16 @@ object TorManager { val currentTime = System.currentTimeMillis() val lastActivity = lastLogTime.get() val timeSinceLastActivity = currentTime - lastActivity - + if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) { val currentMode = _status.value.mode if (currentMode == TorMode.ON) { val bootstrapPercent = _status.value.bootstrapPercent if (bootstrapPercent < 100) { Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti") - currentApplication?.let { app -> appScope.launch { - restartArti(app) + restartArti(application) } - } break } } diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseChannelEncryption.kt b/app/src/main/java/com/bitchat/android/noise/NoiseChannelEncryption.kt index dce6dd938..a4a6ba0e1 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseChannelEncryption.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseChannelEncryption.kt @@ -1,6 +1,8 @@ package com.bitchat.android.noise import android.util.Log +import com.bitchat.android.util.JsonUtil +import kotlinx.serialization.json.jsonObject import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap import javax.crypto.Cipher @@ -210,7 +212,7 @@ class NoiseChannelEncryption { ) // Simple JSON encoding for now (could be replaced with more efficient format) - val json = com.google.gson.Gson().toJson(packet) + val json = JsonUtil.toJson(packet) json.toByteArray(Charsets.UTF_8) } catch (e: Exception) { Log.e(TAG, "Failed to create channel key packet: ${e.message}") @@ -225,7 +227,14 @@ class NoiseChannelEncryption { fun processChannelKeyPacket(data: ByteArray): Pair? { return try { val json = String(data, Charsets.UTF_8) - val packet = com.google.gson.Gson().fromJson(json, Map::class.java) as Map + val packet = try { + JsonUtil.json.parseToJsonElement(json).jsonObject.mapValues { + when (val value = it.value) { + is kotlinx.serialization.json.JsonPrimitive -> if (value.isString) value.content else value.toString() + else -> value.toString() + } + } + } catch (e: Exception) { return null } val channel = packet["channel"] as? String val password = packet["password"] as? String diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt index f9e969d34..0e0d638f1 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt @@ -1,61 +1,57 @@ package com.bitchat.android.noise -import android.content.Context import android.util.Log import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.mesh.PeerFingerprintManager import com.bitchat.android.noise.southernstorm.protocol.Noise +import jakarta.inject.Inject +import jakarta.inject.Singleton import java.security.MessageDigest import java.security.SecureRandom import java.util.concurrent.ConcurrentHashMap /** * Main Noise encryption service - 100% compatible with iOS implementation - * + * * This service manages: * - Static identity keys (persistent across sessions) * - Noise session management for each peer * - Channel encryption using password-derived keys * - Peer fingerprint mapping and identity persistence */ -class NoiseEncryptionService(private val context: Context) { - +@Singleton +class NoiseEncryptionService @Inject constructor( + private val fingerprintManager: PeerFingerprintManager, + private val identityStateManager: SecureIdentityStateManager, +) { + companion object { private const val TAG = "NoiseEncryptionService" - + // Session limits for performance and security private const val REKEY_TIME_LIMIT = com.bitchat.android.util.AppConstants.Noise.REKEY_TIME_LIMIT_MS // 1 hour (same as iOS) private const val REKEY_MESSAGE_LIMIT = com.bitchat.android.util.AppConstants.Noise.REKEY_MESSAGE_LIMIT_ENCRYPTION // 1k messages (matches iOS) (same as iOS) } - + // Static identity key (persistent across app restarts) - loaded from secure storage private val staticIdentityPrivateKey: ByteArray private val staticIdentityPublicKey: ByteArray - + // Ed25519 signing key (persistent across app restarts) - loaded from secure storage private val signingPrivateKey: ByteArray private val signingPublicKey: ByteArray - + // Session management private val sessionManager: NoiseSessionManager - + // Channel encryption for password-protected channels private val channelEncryption = NoiseChannelEncryption() - - // Identity management for peer ID rotation support - private val identityStateManager: SecureIdentityStateManager - - // Centralized fingerprint management - NO LOCAL STORAGE - private val fingerprintManager = PeerFingerprintManager.getInstance() - + // Callbacks var onPeerAuthenticated: ((String, String) -> Unit)? = null // (peerID, fingerprint) var onHandshakeRequired: ((String) -> Unit)? = null // peerID needs handshake - + init { - // Initialize identity state manager for persistent storage - identityStateManager = SecureIdentityStateManager(context) - // Load or create static identity key (persistent across sessions) val loadedKeyPair = identityStateManager.loadStaticKey() if (loadedKeyPair != null) { @@ -67,12 +63,12 @@ class NoiseEncryptionService(private val context: Context) { val keyPair = generateKeyPair() staticIdentityPrivateKey = keyPair.first staticIdentityPublicKey = keyPair.second - + // Save to secure storage identityStateManager.saveStaticKey(staticIdentityPrivateKey, staticIdentityPublicKey) Log.d(TAG, "Generated and saved new static identity key") } - + // Load or create Ed25519 signing key (persistent across sessions) val loadedSigningKeyPair = identityStateManager.loadSigningKey() if (loadedSigningKeyPair != null) { @@ -84,23 +80,23 @@ class NoiseEncryptionService(private val context: Context) { val signingKeyPair = generateEd25519KeyPair() signingPrivateKey = signingKeyPair.first signingPublicKey = signingKeyPair.second - + // Save to secure storage identityStateManager.saveSigningKey(signingPrivateKey, signingPublicKey) Log.d(TAG, "Generated and saved new Ed25519 signing key") } - + // Initialize session manager sessionManager = NoiseSessionManager(staticIdentityPrivateKey, staticIdentityPublicKey) - + // Set up session callbacks sessionManager.onSessionEstablished = { peerID, remoteStaticKey -> handleSessionEstablished(peerID, remoteStaticKey) } } - + // MARK: - Public Interface - + /** * Get our static public key data for sharing (32 bytes) */ @@ -114,7 +110,7 @@ class NoiseEncryptionService(private val context: Context) { fun getSigningPublicKeyData(): ByteArray { return signingPublicKey.clone() } - + /** * Get our identity fingerprint (SHA-256 hash of static public key) */ @@ -123,23 +119,23 @@ class NoiseEncryptionService(private val context: Context) { val hash = digest.digest(staticIdentityPublicKey) return hash.joinToString("") { "%02x".format(it) } } - + /** * Get peer's public key data (if we have a session) */ fun getPeerPublicKeyData(peerID: String): ByteArray? { return sessionManager.getRemoteStaticKey(peerID) } - + /** * Clear persistent identity (for panic mode) */ fun clearPersistentIdentity() { identityStateManager.clearIdentityData() } - + // MARK: - Handshake Management - + /** * Initiate a Noise handshake with a peer * Returns the first handshake message to send @@ -152,7 +148,7 @@ class NoiseEncryptionService(private val context: Context) { null } } - + /** * Process an incoming handshake message * Returns response message if needed, null if handshake complete or failed @@ -165,23 +161,23 @@ class NoiseEncryptionService(private val context: Context) { null } } - + /** * Check if we have an established session with a peer */ fun hasEstablishedSession(peerID: String): Boolean { return sessionManager.hasEstablishedSession(peerID) } - + /** * Get session state for a peer (for UI state display) */ fun getSessionState(peerID: String): NoiseSession.NoiseSessionState { return sessionManager.getSessionState(peerID) } - + // MARK: - Encryption/Decryption - + /** * Encrypt data for a specific peer using established Noise session */ @@ -191,7 +187,7 @@ class NoiseEncryptionService(private val context: Context) { onHandshakeRequired?.invoke(peerID) return null } - + return try { sessionManager.encrypt(data, peerID) } catch (e: Exception) { @@ -199,7 +195,7 @@ class NoiseEncryptionService(private val context: Context) { null } } - + /** * Decrypt data from a specific peer using established Noise session */ @@ -208,7 +204,7 @@ class NoiseEncryptionService(private val context: Context) { Log.w(TAG, "No established session with $peerID") return null } - + return try { sessionManager.decrypt(encryptedData, peerID) } catch (e: Exception) { @@ -216,33 +212,33 @@ class NoiseEncryptionService(private val context: Context) { null } } - + // MARK: - Peer Management - + /** * Get fingerprint for a peer (returns null if peer unknown) */ fun getPeerFingerprint(peerID: String): String? { return fingerprintManager.getFingerprintForPeer(peerID) } - + /** * Get current peer ID for a fingerprint (returns null if not currently online) */ fun getPeerID(fingerprint: String): String? { return fingerprintManager.getPeerIDForFingerprint(fingerprint) } - + /** * Remove a peer session (called when peer disconnects) */ fun removePeer(peerID: String) { sessionManager.removeSession(peerID) - + // Clean up fingerprint mappings via centralized manager fingerprintManager.removePeer(peerID) } - + /** * Update peer ID mapping (for peer ID rotation) * This allows favorites/blocking to persist across peer ID changes @@ -251,16 +247,16 @@ class NoiseEncryptionService(private val context: Context) { // Use centralized fingerprint manager for peer ID rotation fingerprintManager.updatePeerIDMapping(oldPeerID, newPeerID, fingerprint) } - + // MARK: - Channel Encryption - + /** * Set password for a channel (derives encryption key) */ fun setChannelPassword(password: String, channel: String) { channelEncryption.setChannelPassword(password, channel) } - + /** * Encrypt message for a password-protected channel */ @@ -272,7 +268,7 @@ class NoiseEncryptionService(private val context: Context) { null } } - + /** * Decrypt channel message */ @@ -284,38 +280,38 @@ class NoiseEncryptionService(private val context: Context) { null } } - + /** * Remove channel password (when leaving channel) */ fun removeChannelPassword(channel: String) { channelEncryption.removeChannelPassword(channel) } - + // MARK: - Session Maintenance - + /** * Get sessions that need rekey based on time or message count */ fun getSessionsNeedingRekey(): List { return sessionManager.getSessionsNeedingRekey() } - + /** * Initiate rekey for a session (replaces old session with new handshake) */ fun initiateRekey(peerID: String): ByteArray? { Log.d(TAG, "Initiating rekey for session with $peerID") - + // Remove old session sessionManager.removeSession(peerID) - + // Start new handshake return initiateHandshake(peerID) } - + // MARK: - Private Helpers - + /** * Generate a new Curve25519 key pair using the real Noise library * Returns (privateKey, publicKey) as 32-byte arrays @@ -324,22 +320,22 @@ class NoiseEncryptionService(private val context: Context) { try { val dhState = com.bitchat.android.noise.southernstorm.protocol.Noise.createDH("25519") dhState.generateKeyPair() - + val privateKey = ByteArray(32) val publicKey = ByteArray(32) - + dhState.getPrivateKey(privateKey, 0) dhState.getPublicKey(publicKey, 0) - + dhState.destroy() - + return Pair(privateKey, publicKey) } catch (e: Exception) { Log.e(TAG, "Failed to generate key pair: ${e.message}") throw e } } - + /** * Handle session establishment (called when Noise handshake completes) */ @@ -347,16 +343,16 @@ class NoiseEncryptionService(private val context: Context) { // Store fingerprint mapping via centralized manager // This is the ONLY place where fingerprints are stored - after successful Noise handshake fingerprintManager.storeFingerprintForPeer(peerID, remoteStaticKey) - + // Calculate fingerprint for logging and callback val fingerprint = calculateFingerprint(remoteStaticKey) - + Log.d(TAG, "Session established with $peerID, fingerprint: ${fingerprint.take(16)}...") - + // Notify about authentication onPeerAuthenticated?.invoke(peerID, fingerprint) } - + /** * Calculate fingerprint from public key (SHA-256 hash) */ @@ -365,7 +361,7 @@ class NoiseEncryptionService(private val context: Context) { val hash = digest.digest(publicKey) return hash.joinToString("") { "%02x".format(it) } } - + // MARK: - Packet Signing/Verification /** @@ -374,10 +370,10 @@ class NoiseEncryptionService(private val context: Context) { fun signPacket(packet: com.bitchat.android.protocol.BitchatPacket): com.bitchat.android.protocol.BitchatPacket? { // Create canonical packet bytes for signing val packetData = packet.toBinaryDataForSigning() ?: return null - + // Sign with our Ed25519 signing private key val signature = signData(packetData) ?: return null - + // Return new packet with signature return packet.copy(signature = signature) } @@ -387,10 +383,10 @@ class NoiseEncryptionService(private val context: Context) { */ fun verifyPacketSignature(packet: com.bitchat.android.protocol.BitchatPacket, publicKey: ByteArray): Boolean { val signature = packet.signature ?: return false - + // Create canonical packet bytes for verification (without signature) val packetData = packet.toBinaryDataForSigning() ?: return false - + // Verify signature using the provided Ed25519 public key return verifySignature(signature, packetData, publicKey) } @@ -431,10 +427,10 @@ class NoiseEncryptionService(private val context: Context) { val keyGen = org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator() keyGen.init(org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters(SecureRandom())) val keyPair = keyGen.generateKeyPair() - + val privateKey = (keyPair.private as org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters).encoded val publicKey = (keyPair.public as org.bouncycastle.crypto.params.Ed25519PublicKeyParameters).encoded - + return Pair(privateKey, publicKey) } catch (e: Exception) { Log.e(TAG, "Failed to generate Ed25519 key pair: ${e.message}") diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index 0e6e66345..16824f656 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -23,7 +23,8 @@ class GeohashMessageHandler( private val messageManager: MessageManager, private val repo: GeohashRepository, private val scope: CoroutineScope, - private val dataManager: com.bitchat.android.ui.DataManager + private val dataManager: com.bitchat.android.ui.DataManager, + private val powPreferenceManager: PoWPreferenceManager ) { companion object { private const val TAG = "GeohashMessageHandler" } @@ -52,7 +53,7 @@ class GeohashMessageHandler( if (dedupe(event.id)) return@launch // PoW validation (if enabled) - val pow = PoWPreferenceManager.getCurrentSettings() + val pow = powPreferenceManager.getCurrentSettings() if (pow.enabled && pow.difficulty > 0) { if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch } diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt deleted file mode 100644 index bed29902e..000000000 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.bitchat.android.nostr - -import android.content.Context -import android.util.Log - -/** - * Initializer for LocationNotesManager with all dependencies - * Extracts initialization logic from MainActivity for better separation of concerns - */ -object LocationNotesInitializer { - - private const val TAG = "LocationNotesInitializer" - - /** - * Initialize LocationNotesManager with all required dependencies - * - * @param context Application context - * @return true if initialization succeeded, false otherwise - */ - fun initialize(context: Context): Boolean { - return try { - LocationNotesManager.getInstance().initialize( - relayManager = { NostrRelayManager.getInstance(context) }, - subscribe = { filter, id, handler -> - // CRITICAL FIX: Extract geohash properly from filter using getGeohash() method - val geohashFromFilter = filter.getGeohash() ?: run { - Log.e(TAG, "❌ Cannot extract geohash from filter for location notes") - return@initialize id // Return subscription ID even on error - } - - Log.d(TAG, "📍 Location Notes subscribing to geohash: $geohashFromFilter") - - NostrRelayManager.getInstance(context).subscribeForGeohash( - geohash = geohashFromFilter, - filter = filter, - id = id, - handler = handler, - includeDefaults = true, - nRelays = 5 - ) - }, - unsubscribe = { id -> - NostrRelayManager.getInstance(context).unsubscribe(id) - }, - sendEvent = { event, relayUrls -> - if (relayUrls != null) { - NostrRelayManager.getInstance(context).sendEvent(event, relayUrls) - } else { - NostrRelayManager.getInstance(context).sendEvent(event) - } - }, - deriveIdentity = { geohash -> - NostrIdentityBridge.deriveIdentity(geohash, context) - } - ) - Log.d(TAG, "✅ Location Notes Manager initialized") - true - } catch (e: Exception) { - Log.e(TAG, "❌ Failed to initialize Location Notes Manager: ${e.message}", e) - false - } - } -} diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt index 655a97834..b0a416f3c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -1,30 +1,28 @@ package com.bitchat.android.nostr +import android.content.Context import android.util.Log import androidx.annotation.MainThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.* +import jakarta.inject.Singleton /** * Manages location notes (kind=1 text notes with geohash tags) * iOS-compatible implementation with LiveData for Android UI binding */ @MainThread -class LocationNotesManager private constructor() { +@Singleton +class LocationNotesManager( + private val context: Context, + private val relayManager: NostrRelayManager, + private val relayDirectory: RelayDirectory +) { companion object { private const val TAG = "LocationNotesManager" private const val MAX_NOTES_IN_MEMORY = 500 - - @Volatile - private var INSTANCE: LocationNotesManager? = null - - fun getInstance(): LocationNotesManager { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: LocationNotesManager().also { INSTANCE = it } - } - } } /** @@ -84,33 +82,9 @@ class LocationNotesManager private constructor() { private val noteIDs = mutableSetOf() // For deduplication private var subscribedGeohashes: Set = emptySet() - // Dependencies (injected via setters for flexibility) - private var relayLookup: (() -> NostrRelayManager)? = null - private var subscribeFunc: ((NostrFilter, String, (NostrEvent) -> Unit) -> String)? = null - private var unsubscribeFunc: ((String) -> Unit)? = null - private var sendEventFunc: ((NostrEvent, List?) -> Unit)? = null - private var deriveIdentityFunc: ((String) -> NostrIdentity)? = null - // Coroutine scope for background operations private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) - /** - * Initialize dependencies - */ - fun initialize( - relayManager: () -> NostrRelayManager, - subscribe: (NostrFilter, String, (NostrEvent) -> Unit) -> String, - unsubscribe: (String) -> Unit, - sendEvent: (NostrEvent, List?) -> Unit, - deriveIdentity: (String) -> NostrIdentity - ) { - this.relayLookup = relayManager - this.subscribeFunc = subscribe - this.unsubscribeFunc = unsubscribe - this.sendEventFunc = sendEvent - this.deriveIdentityFunc = deriveIdentity - } - /** * Set geohash and start subscription * iOS: Validates building-level precision (8 characters) @@ -207,7 +181,7 @@ class LocationNotesManager private constructor() { // CRITICAL FIX: Get geo-specific relays for sending (matching iOS pattern) // iOS: let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) val relays = try { - com.bitchat.android.nostr.RelayDirectory.closestRelaysForGeohash(currentGeohash, 5) + relayDirectory.closestRelaysForGeohash(currentGeohash, 5) } catch (e: Exception) { Log.e(TAG, "Failed to lookup relays for geohash $currentGeohash: ${e.message}") emptyList() @@ -221,19 +195,12 @@ class LocationNotesManager private constructor() { return } - val deriveIdentity = deriveIdentityFunc - if (deriveIdentity == null) { - Log.e(TAG, "Cannot send note - deriveIdentity not initialized") - _errorMessage.value = "Not initialized" - return - } - Log.d(TAG, "Sending note to geohash: $currentGeohash via ${relays.size} geo relays") scope.launch { try { val identity = withContext(Dispatchers.IO) { - deriveIdentity(currentGeohash) + NostrIdentityBridge.deriveIdentity(currentGeohash, context) } val event = withContext(Dispatchers.IO) { @@ -268,7 +235,7 @@ class LocationNotesManager private constructor() { // CRITICAL FIX: Send to geo-specific relays (matching iOS pattern) // iOS: dependencies.sendEvent(event, relays) withContext(Dispatchers.IO) { - sendEventFunc?.invoke(event, relays) + relayManager.sendEvent(event, relays) } Log.d(TAG, "✅ Note sent successfully to ${relays.size} geo relays: ${event.id.take(16)}...") @@ -294,32 +261,6 @@ class LocationNotesManager private constructor() { _state.value = State.IDLE return } - - val subscribe = subscribeFunc - if (subscribe == null) { - Log.e(TAG, "Cannot subscribe - subscribe function not initialized; will retry shortly") - _state.value = State.LOADING - // Retry a few times in case initialization is racing the sheet open - scope.launch { - var attempts = 0 - while (attempts < 10 && subscribeFunc == null) { - delay(300) - attempts++ - } - val subNow = subscribeFunc - if (subNow != null) { - // Try again now that dependencies are ready - subscribeAll() - } else { - // Give UI a chance to show empty state rather than spinner forever - if (!_initialLoadComplete.value!!) { - _initialLoadComplete.value = true - _state.value = State.READY - } - } - } - return - } _state.value = State.LOADING @@ -333,7 +274,14 @@ class LocationNotesManager private constructor() { val subId = "location-notes-$gh" Log.d(TAG, "📡 Subscribing to location notes: $subId") try { - val id = subscribe(filter, subId) { event -> handleEvent(event) } + val id = relayManager.subscribeForGeohash( + geohash = gh, + filter = filter, + id = subId, + handler = { event -> handleEvent(event) }, + includeDefaults = true, + nRelays = 5 + ) subscriptionIDs[gh] = id } catch (e: Exception) { Log.e(TAG, "Failed to subscribe for $gh: ${e.message}") @@ -444,7 +392,7 @@ class LocationNotesManager private constructor() { subscriptionIDs.values.forEach { subId -> try { Log.d(TAG, "🚫 Canceling subscription: $subId") - unsubscribeFunc?.invoke(subId) + relayManager.unsubscribe(subId) } catch (_: Exception) { } } subscriptionIDs.clear() diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt index 9e34c34c9..6266afd05 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -4,29 +4,26 @@ import android.content.Context import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import jakarta.inject.Inject +import jakarta.inject.Singleton import kotlinx.coroutines.* /** * High-level Nostr client that manages identity, connections, and messaging * Provides a simple API for the rest of the application */ -class NostrClient private constructor(private val context: Context) { +@Singleton +class NostrClient @Inject constructor( + private val context: Context, + private val relayManager: NostrRelayManager, + private val poWPreferenceManager: PoWPreferenceManager +) { companion object { private const val TAG = "NostrClient" - - @Volatile - private var INSTANCE: NostrClient? = null - - fun getInstance(context: Context): NostrClient { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: NostrClient(context.applicationContext).also { INSTANCE = it } - } - } } // Core components - private val relayManager = NostrRelayManager.shared private var currentIdentity: NostrIdentity? = null // Client state @@ -116,7 +113,7 @@ class NostrClient private constructor(private val context: Context) { // Track and send all gift wraps giftWraps.forEach { wrap -> - NostrRelayManager.registerPendingGiftWrap(wrap.id) + relayManager.registerPendingGiftWrap(wrap.id) relayManager.sendEvent(wrap) } @@ -174,7 +171,8 @@ class NostrClient private constructor(private val context: Context) { content = content, geohash = geohash, senderIdentity = geohashIdentity, - nickname = nickname + nickname = nickname, + powPreferenceManager = poWPreferenceManager ) relayManager.sendEvent(event) @@ -282,7 +280,7 @@ class NostrClient private constructor(private val context: Context) { ) { try { // Check Proof of Work validation for incoming geohash events - val powSettings = PoWPreferenceManager.getCurrentSettings() + val powSettings = poWPreferenceManager.getCurrentSettings() if (powSettings.enabled && powSettings.difficulty > 0) { if (!NostrProofOfWork.validateDifficulty(event, powSettings.difficulty)) { Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... due to insufficient PoW (required: ${powSettings.difficulty})") diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 3dcb60d68..ee5e0462b 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -22,12 +22,12 @@ class NostrDirectMessageHandler( private val meshDelegateHandler: MeshDelegateHandler, private val scope: CoroutineScope, private val repo: GeohashRepository, - private val dataManager: com.bitchat.android.ui.DataManager + private val dataManager: com.bitchat.android.ui.DataManager, + private val nostrTransport: NostrTransport, + private val seenStore: SeenMessageStore ) { companion object { private const val TAG = "NostrDirectMessageHandler" } - private val seenStore by lazy { SeenMessageStore.getInstance(application) } - // Simple event deduplication private val processedIds = ArrayDeque() private val seen = HashSet() @@ -137,13 +137,11 @@ class NostrDirectMessageHandler( } if (!seenStore.hasDelivered(pm.messageID)) { - val nostrTransport = NostrTransport.getInstance(application) nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity) seenStore.markDelivered(pm.messageID) } if (isViewing && !suppressUnread) { - val nostrTransport = NostrTransport.getInstance(application) nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity) seenStore.markRead(pm.messageID) } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt index 785b41f37..c7535306b 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt @@ -1,18 +1,19 @@ package com.bitchat.android.nostr -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerialName +import com.bitchat.android.util.JsonUtil import java.security.MessageDigest /** * Nostr Event structure following NIP-01 * Compatible with iOS implementation */ +@Serializable data class NostrEvent( var id: String = "", val pubkey: String, - @SerializedName("created_at") val createdAt: Int, + @SerialName("created_at") val createdAt: Int, val kind: Int, val tags: List>, val content: String, @@ -43,12 +44,7 @@ data class NostrEvent( * Create from JSON string */ fun fromJsonString(jsonString: String): NostrEvent? { - return try { - val gson = Gson() - gson.fromJson(jsonString, NostrEvent::class.java) - } catch (e: Exception) { - null - } + return JsonUtil.fromJsonOrNull(jsonString) } /** @@ -131,8 +127,7 @@ data class NostrEvent( ) // Convert to JSON without escaping slashes (compact format) - val gson = GsonBuilder().disableHtmlEscaping().create() - val jsonString = gson.toJson(serialized) + val jsonString = JsonUtil.toJson(serialized) // SHA256 hash of the JSON string val digest = MessageDigest.getInstance("SHA-256") @@ -161,8 +156,7 @@ data class NostrEvent( * Convert to JSON string */ fun toJsonString(): String { - val gson = Gson() - return gson.toJson(this) + return JsonUtil.toJson(this) } /** diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt index 0638eafd9..0c8b3c212 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEventDeduplicator.kt @@ -2,6 +2,8 @@ package com.bitchat.android.nostr import android.util.Log import java.util.concurrent.ConcurrentHashMap +import jakarta.inject.Inject +import jakarta.inject.Singleton /** * Efficient LRU-based Nostr event deduplication system @@ -17,26 +19,15 @@ import java.util.concurrent.ConcurrentHashMap * - Efficient O(1) lookup and insertion * - Memory-bounded to prevent unbounded growth */ -class NostrEventDeduplicator( - private val maxCapacity: Int = DEFAULT_CAPACITY -) { +@Singleton +class NostrEventDeduplicator @Inject constructor() { companion object { private const val TAG = "NostrDeduplicator" private const val DEFAULT_CAPACITY = com.bitchat.android.util.AppConstants.Nostr.DEFAULT_DEDUP_CAPACITY - - @Volatile - private var INSTANCE: NostrEventDeduplicator? = null - - /** - * Get the singleton instance of the deduplicator - */ - fun getInstance(): NostrEventDeduplicator { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: NostrEventDeduplicator().also { INSTANCE = it } - } - } } + private val maxCapacity: Int = DEFAULT_CAPACITY + /** * Node for the doubly-linked list used in LRU implementation */ diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt index b6313ea7a..cc52b2989 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt @@ -1,13 +1,13 @@ package com.bitchat.android.nostr -import com.google.gson.* -import com.google.gson.annotations.SerializedName -import java.lang.reflect.Type +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.* /** * Nostr event filter for subscriptions * Compatible with iOS implementation */ +@Serializable data class NostrFilter( val ids: List? = null, val authors: List? = null, @@ -17,125 +17,33 @@ data class NostrFilter( val limit: Int? = null, private val tagFilters: Map>? = null ) { - - companion object { - /** - * Create filter for NIP-17 gift wraps - */ - fun giftWrapsFor(pubkey: String, since: Long? = null): NostrFilter { - return NostrFilter( - kinds = listOf(NostrKind.GIFT_WRAP), - since = since?.let { (it / 1000).toInt() }, - tagFilters = mapOf("p" to listOf(pubkey)), - limit = 100 - ) - } - - /** - * Create filter for geohash-scoped ephemeral events (kind 20000) - */ - fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter { - return NostrFilter( - kinds = listOf(NostrKind.EPHEMERAL_EVENT), - since = since?.let { (it / 1000).toInt() }, - tagFilters = mapOf("g" to listOf(geohash)), - limit = limit - ) - } - - /** - * Create filter for text notes from specific authors - */ - fun textNotesFrom(authors: List, since: Long? = null, limit: Int = 50): NostrFilter { - return NostrFilter( - kinds = listOf(NostrKind.TEXT_NOTE), - authors = authors, - since = since?.let { (it / 1000).toInt() }, - limit = limit - ) - } - - /** - * Create filter for geohash-scoped text notes (kind=1 with g tag) - */ - fun geohashNotes(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter { - return NostrFilter( - kinds = listOf(NostrKind.TEXT_NOTE), - since = since?.let { (it / 1000).toInt() }, - tagFilters = mapOf("g" to listOf(geohash)), - limit = limit - ) - } - - /** - * Create filter for specific event IDs - */ - fun forEvents(ids: List): NostrFilter { - return NostrFilter(ids = ids) - } + /** + * Convert NostrFilter to JsonElement for serialization + */ + fun toJsonElement(filter: NostrFilter): JsonElement { + return filter.toJsonElement() } - + /** - * Custom JSON serializer to handle tag filters properly + * Convert this filter to JsonElement for serialization */ - class FilterSerializer : JsonSerializer { - override fun serialize(src: NostrFilter, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { - val jsonObject = JsonObject() - + fun toJsonElement(): JsonElement { + return buildJsonObject { // Standard fields - src.ids?.let { jsonObject.add("ids", context.serialize(it)) } - src.authors?.let { jsonObject.add("authors", context.serialize(it)) } - src.kinds?.let { jsonObject.add("kinds", context.serialize(it)) } - src.since?.let { jsonObject.addProperty("since", it) } - src.until?.let { jsonObject.addProperty("until", it) } - src.limit?.let { jsonObject.addProperty("limit", it) } - + ids?.let { put("ids", JsonArray(it.map { JsonPrimitive(it) })) } + authors?.let { put("authors", JsonArray(it.map { JsonPrimitive(it) })) } + kinds?.let { put("kinds", JsonArray(it.map { JsonPrimitive(it) })) } + since?.let { put("since", JsonPrimitive(it)) } + until?.let { put("until", JsonPrimitive(it)) } + limit?.let { put("limit", JsonPrimitive(it)) } + // Tag filters with # prefix - src.tagFilters?.forEach { (tag, values) -> - jsonObject.add("#$tag", context.serialize(values)) + tagFilters?.forEach { (tag, values) -> + put("#$tag", JsonArray(values.map { JsonPrimitive(it) })) } - - return jsonObject - } - } - - /** - * Create builder for complex filters - */ - class Builder { - private var ids: List? = null - private var authors: List? = null - private var kinds: List? = null - private var since: Int? = null - private var until: Int? = null - private var limit: Int? = null - private val tagFilters = mutableMapOf>() - - fun ids(vararg ids: String) = apply { this.ids = ids.toList() } - fun authors(vararg authors: String) = apply { this.authors = authors.toList() } - fun kinds(vararg kinds: Int) = apply { this.kinds = kinds.toList() } - fun since(timestamp: Long) = apply { this.since = (timestamp / 1000).toInt() } - fun until(timestamp: Long) = apply { this.until = (timestamp / 1000).toInt() } - fun limit(count: Int) = apply { this.limit = count } - - fun tagP(vararg pubkeys: String) = apply { tagFilters["p"] = pubkeys.toList() } - fun tagE(vararg eventIds: String) = apply { tagFilters["e"] = eventIds.toList() } - fun tagG(vararg geohashes: String) = apply { tagFilters["g"] = geohashes.toList() } - fun tag(name: String, vararg values: String) = apply { tagFilters[name] = values.toList() } - - fun build(): NostrFilter { - return NostrFilter( - ids = ids, - authors = authors, - kinds = kinds, - since = since, - until = until, - limit = limit, - tagFilters = tagFilters.toMap() - ) } } - + /** * Check if this filter matches an event */ @@ -144,26 +52,26 @@ data class NostrFilter( if (ids != null && !ids.contains(event.id)) { return false } - + // Check authors if (authors != null && !authors.contains(event.pubkey)) { return false } - + // Check kinds if (kinds != null && !kinds.contains(event.kind)) { return false } - + // Check time bounds if (since != null && event.createdAt < since) { return false } - + if (until != null && event.createdAt > until) { return false } - + // Check tag filters if (tagFilters != null) { for ((tagName, requiredValues) in tagFilters) { @@ -171,26 +79,26 @@ data class NostrFilter( val eventValues = eventTags.mapNotNull { tag -> if (tag.size > 1) tag[1] else null } - + val hasMatch = requiredValues.any { requiredValue -> eventValues.contains(requiredValue) } - + if (!hasMatch) { return false } } } - + return true } - + /** * Get debug description */ fun getDebugDescription(): String { val parts = mutableListOf() - + ids?.let { parts.add("ids=${it.size}") } authors?.let { parts.add("authors=${it.size}") } kinds?.let { parts.add("kinds=$it") } @@ -202,10 +110,105 @@ data class NostrFilter( parts.add("#$tag=${values.size}") } } - + return "NostrFilter(${parts.joinToString(", ")})" } + companion object { + /** + * Create filter for NIP-17 gift wraps + */ + fun giftWrapsFor(pubkey: String, since: Long? = null): NostrFilter { + return NostrFilter( + kinds = listOf(NostrKind.GIFT_WRAP), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("p" to listOf(pubkey)), + limit = 100 + ) + } + + /** + * Create filter for geohash-scoped ephemeral events (kind 20000) + */ + fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter { + return NostrFilter( + kinds = listOf(NostrKind.EPHEMERAL_EVENT), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("g" to listOf(geohash)), + limit = limit + ) + } + + /** + * Create filter for text notes from specific authors + */ + fun textNotesFrom(authors: List, since: Long? = null, limit: Int = 50): NostrFilter { + return NostrFilter( + kinds = listOf(NostrKind.TEXT_NOTE), + authors = authors, + since = since?.let { (it / 1000).toInt() }, + limit = limit + ) + } + + /** + * Create filter for geohash-scoped text notes (kind=1 with g tag) + */ + fun geohashNotes(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter { + return NostrFilter( + kinds = listOf(NostrKind.TEXT_NOTE), + since = since?.let { (it / 1000).toInt() }, + tagFilters = mapOf("g" to listOf(geohash)), + limit = limit + ) + } + + /** + * Create filter for specific event IDs + */ + fun forEvents(ids: List): NostrFilter { + return NostrFilter(ids = ids) + } + + /** + * Create builder for complex filters + */ + class Builder { + private var ids: List? = null + private var authors: List? = null + private var kinds: List? = null + private var since: Int? = null + private var until: Int? = null + private var limit: Int? = null + private val tagFilters = mutableMapOf>() + + fun ids(vararg ids: String) = apply { this.ids = ids.toList() } + fun authors(vararg authors: String) = apply { this.authors = authors.toList() } + fun kinds(vararg kinds: Int) = apply { this.kinds = kinds.toList() } + fun since(timestamp: Long) = apply { this.since = (timestamp / 1000).toInt() } + fun until(timestamp: Long) = apply { this.until = (timestamp / 1000).toInt() } + fun limit(count: Int) = apply { this.limit = count } + + fun tagP(vararg pubkeys: String) = apply { tagFilters["p"] = pubkeys.toList() } + fun tagE(vararg eventIds: String) = apply { tagFilters["e"] = eventIds.toList() } + fun tagG(vararg geohashes: String) = apply { tagFilters["g"] = geohashes.toList() } + fun tag(name: String, vararg values: String) = + apply { tagFilters[name] = values.toList() } + + fun build(): NostrFilter { + return NostrFilter( + ids = ids, + authors = authors, + kinds = kinds, + since = since, + until = until, + limit = limit, + tagFilters = tagFilters.toMap() + ) + } + } + } + /** * Get geohash value from g tag filter (if present) * Returns the first geohash in the filter or null if none diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 0b94bf78c..c9e2ae9e5 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -1,8 +1,8 @@ package com.bitchat.android.nostr import android.util.Log -import com.google.gson.Gson -import com.google.gson.JsonParser +import kotlinx.serialization.json.* +import com.bitchat.android.util.JsonUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -13,7 +13,7 @@ import kotlinx.coroutines.withContext object NostrProtocol { private const val TAG = "NostrProtocol" - private val gson = Gson() + /** * Create NIP-17 private message gift-wrap (receiver copy only per iOS) @@ -127,7 +127,8 @@ object NostrProtocol { geohash: String, senderIdentity: NostrIdentity, nickname: String? = null, - teleported: Boolean = false + teleported: Boolean = false, + powPreferenceManager: PoWPreferenceManager ): NostrEvent = withContext(Dispatchers.Default) { val tags = mutableListOf>() tags.add(listOf("g", geohash)) @@ -150,13 +151,13 @@ object NostrProtocol { ) // Check if Proof of Work is enabled - val powSettings = PoWPreferenceManager.getCurrentSettings() + val powSettings = powPreferenceManager.getCurrentSettings() if (powSettings.enabled && powSettings.difficulty > 0) { Log.d(TAG, "PoW enabled for geohash event: difficulty=${powSettings.difficulty}") try { // Start mining state for animated indicators - PoWPreferenceManager.startMining() + powPreferenceManager.startMining() // Mine the event before signing val minedEvent = NostrProofOfWork.mineEvent( @@ -174,7 +175,7 @@ object NostrProtocol { } } finally { // Always stop mining state when done (success or failure) - PoWPreferenceManager.stopMining() + powPreferenceManager.stopMining() } } @@ -189,7 +190,7 @@ object NostrProtocol { senderPrivateKey: String, senderPublicKey: String ): NostrEvent { - val rumorJSON = gson.toJson(rumor) + val rumorJSON = JsonUtil.toJson(rumor) val encrypted = NostrCrypto.encryptNIP44( plaintext = rumorJSON, @@ -213,7 +214,7 @@ object NostrProtocol { seal: NostrEvent, recipientPubkey: String ): NostrEvent { - val sealJSON = gson.toJson(seal) + val sealJSON = JsonUtil.toJson(seal) // Create new ephemeral key for gift wrap val (wrapPrivateKey, wrapPublicKey) = NostrCrypto.generateKeyPair() @@ -251,21 +252,21 @@ object NostrProtocol { recipientPrivateKeyHex = recipientPrivateKey ) - val jsonElement = JsonParser.parseString(decrypted) - if (!jsonElement.isJsonObject) { + val jsonElement = JsonUtil.json.parseToJsonElement(decrypted) + if (jsonElement !is JsonObject) { Log.w(TAG, "Decrypted gift wrap is not a JSON object") return null } - val jsonObject = jsonElement.asJsonObject + val jsonObject = jsonElement val seal = NostrEvent( - id = jsonObject.get("id")?.asString ?: "", - pubkey = jsonObject.get("pubkey")?.asString ?: "", - createdAt = jsonObject.get("created_at")?.asInt ?: 0, - kind = jsonObject.get("kind")?.asInt ?: 0, - tags = parseTagsFromJson(jsonObject.get("tags")?.asJsonArray) ?: emptyList(), - content = jsonObject.get("content")?.asString ?: "", - sig = jsonObject.get("sig")?.asString + id = jsonObject["id"]?.jsonPrimitive?.content ?: "", + pubkey = jsonObject["pubkey"]?.jsonPrimitive?.content ?: "", + createdAt = jsonObject["created_at"]?.jsonPrimitive?.int ?: 0, + kind = jsonObject["kind"]?.jsonPrimitive?.int ?: 0, + tags = parseTagsFromJson(jsonObject["tags"]?.jsonArray) ?: emptyList(), + content = jsonObject["content"]?.jsonPrimitive?.content ?: "", + sig = jsonObject["sig"]?.jsonPrimitive?.content ) Log.v(TAG, "Unwrapped seal with kind: ${seal.kind}") @@ -287,21 +288,21 @@ object NostrProtocol { recipientPrivateKeyHex = recipientPrivateKey ) - val jsonElement = JsonParser.parseString(decrypted) - if (!jsonElement.isJsonObject) { + val jsonElement = JsonUtil.json.parseToJsonElement(decrypted) + if (jsonElement !is JsonObject) { Log.w(TAG, "Decrypted seal is not a JSON object") return null } - val jsonObject = jsonElement.asJsonObject + val jsonObject = jsonElement NostrEvent( - id = jsonObject.get("id")?.asString ?: "", - pubkey = jsonObject.get("pubkey")?.asString ?: "", - createdAt = jsonObject.get("created_at")?.asInt ?: 0, - kind = jsonObject.get("kind")?.asInt ?: 0, - tags = parseTagsFromJson(jsonObject.get("tags")?.asJsonArray) ?: emptyList(), - content = jsonObject.get("content")?.asString ?: "", - sig = jsonObject.get("sig")?.asString + id = jsonObject["id"]?.jsonPrimitive?.content ?: "", + pubkey = jsonObject["pubkey"]?.jsonPrimitive?.content ?: "", + createdAt = jsonObject["created_at"]?.jsonPrimitive?.int ?: 0, + kind = jsonObject["kind"]?.jsonPrimitive?.int ?: 0, + tags = parseTagsFromJson(jsonObject["tags"]?.jsonArray) ?: emptyList(), + content = jsonObject["content"]?.jsonPrimitive?.content ?: "", + sig = jsonObject["sig"]?.jsonPrimitive?.content ) } catch (e: Exception) { Log.w(TAG, "Failed to open seal: ${e.message}") @@ -309,14 +310,13 @@ object NostrProtocol { } } - private fun parseTagsFromJson(tagsArray: com.google.gson.JsonArray?): List>? { + private fun parseTagsFromJson(tagsArray: JsonArray?): List>? { if (tagsArray == null) return emptyList() return try { tagsArray.map { tagElement -> - if (tagElement.isJsonArray) { - val tagArray = tagElement.asJsonArray - tagArray.map { it.asString } + if (tagElement is JsonArray) { + tagElement.map { it.jsonPrimitive.content } } else { emptyList() } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index eb5806bb5..753a227c0 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -3,9 +3,12 @@ package com.bitchat.android.nostr import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import com.google.gson.Gson -import com.google.gson.JsonArray -import com.google.gson.JsonParser +import com.bitchat.android.net.OkHttpProvider +import com.bitchat.android.net.TorManager +import kotlinx.serialization.json.* +import com.bitchat.android.util.JsonUtil +import jakarta.inject.Inject +import jakarta.inject.Singleton import kotlinx.coroutines.* import okhttp3.* import java.util.concurrent.ConcurrentHashMap @@ -17,21 +20,17 @@ import kotlin.math.pow * Manages WebSocket connections to Nostr relays * Compatible with iOS implementation with Android-specific optimizations */ -class NostrRelayManager private constructor() { +@Singleton +class NostrRelayManager @Inject constructor( + private val okHttpProvider: OkHttpProvider, + private val relayDirectory: RelayDirectory, + private val torManager: TorManager, + private val eventDeduplicator: NostrEventDeduplicator +) { companion object { - @JvmStatic - val shared = NostrRelayManager() - private const val TAG = "NostrRelayManager" - /** - * Get instance for Android compatibility (context-aware calls) - */ - fun getInstance(context: android.content.Context): NostrRelayManager { - return shared - } - // Default relay list (same as iOS) private val DEFAULT_RELAYS = listOf( "wss://relay.damus.io", @@ -46,16 +45,16 @@ class NostrRelayManager private constructor() { private const val BACKOFF_MULTIPLIER = com.bitchat.android.util.AppConstants.Nostr.BACKOFF_MULTIPLIER private const val MAX_RECONNECT_ATTEMPTS = com.bitchat.android.util.AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS - // Track gift-wraps we initiated for logging - private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet() - - fun registerPendingGiftWrap(id: String) { - pendingGiftWrapIDs.add(id) - } - fun defaultRelays(): List = DEFAULT_RELAYS } + // Track gift-wraps we initiated for logging + private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet() + + fun registerPendingGiftWrap(id: String) { + pendingGiftWrapIDs.add(id) + } + /** * Relay status information */ @@ -99,9 +98,6 @@ class NostrRelayManager private constructor() { val originGeohash: String? = null // used for logging and grouping ) - // Event deduplication system - private val eventDeduplicator = NostrEventDeduplicator.getInstance() - // Message queue for reliability private val messageQueue = mutableListOf>>() private val messageQueueLock = Any() @@ -115,9 +111,9 @@ class NostrRelayManager private constructor() { // OkHttp client for WebSocket connections (via provider to honor Tor) private val httpClient: OkHttpClient - get() = com.bitchat.android.net.OkHttpProvider.webSocketClient() + get() = okHttpProvider.webSocketClient() - private val gson by lazy { NostrRequest.createGson() } + // Per-geohash relay selection private val geohashToRelays = ConcurrentHashMap>() // geohash -> relay URLs @@ -129,7 +125,7 @@ class NostrRelayManager private constructor() { */ fun ensureGeohashRelaysConnected(geohash: String, nRelays: Int = 5, includeDefaults: Boolean = false) { try { - val nearest = RelayDirectory.closestRelaysForGeohash(geohash, nRelays) + val nearest = relayDirectory.closestRelaysForGeohash(geohash, nRelays) val selected = if (includeDefaults) { (nearest + Companion.defaultRelays()).toSet() } else nearest.toSet() @@ -235,6 +231,25 @@ class NostrRelayManager private constructor() { _relays.postValue(emptyList()) _isConnected.postValue(false) } + + // Observe Tor status to reset connections when network changes + scope.launch { + var lastMode = com.bitchat.android.net.TorMode.OFF + var lastRunning = false + + torManager.statusFlow.collect { status -> + val modeChanged = status.mode != lastMode + val runningChanged = status.running != lastRunning + + if (modeChanged || (runningChanged && status.running)) { + Log.i(TAG, "Tor status changed (mode=$modeChanged, running=$runningChanged), resetting connections") + resetAllConnections() + } + + lastMode = status.mode + lastRunning = status.running + } + } } /** @@ -331,7 +346,7 @@ class NostrRelayManager private constructor() { */ private fun sendSubscriptionToRelays(subscriptionInfo: SubscriptionInfo) { val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter)) - val message = gson.toJson(request, NostrRequest::class.java) + val message = NostrRequest.toJson(request) // DEBUG: Log the actual serialized message format Log.v(TAG, "🔍 DEBUG: Serialized subscription message: $message") @@ -383,7 +398,7 @@ class NostrRelayManager private constructor() { Log.d(TAG, "🚫 Unsubscribing from subscription: $id") val request = NostrRequest.Close(id) - val message = gson.toJson(request, NostrRequest::class.java) + val message = NostrRequest.toJson(request) scope.launch { connections.forEach { (relayUrl, webSocket) -> @@ -631,7 +646,7 @@ class NostrRelayManager private constructor() { private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String) { try { val request = NostrRequest.Event(event) - val message = gson.toJson(request, NostrRequest::class.java) + val message = NostrRequest.toJson(request) Log.v(TAG, "📤 Sending Nostr event (kind: ${event.kind}) to relay: $relayUrl") @@ -651,13 +666,13 @@ class NostrRelayManager private constructor() { private fun handleMessage(message: String, relayUrl: String) { try { - val jsonElement = JsonParser.parseString(message) - if (!jsonElement.isJsonArray) { + val jsonElement = JsonUtil.json.parseToJsonElement(message) + if (jsonElement !is JsonArray) { Log.w(TAG, "Received non-array message from $relayUrl") return } - val response = NostrResponse.fromJsonArray(jsonElement.asJsonArray) + val response = NostrResponse.fromJsonArray(jsonElement) when (response) { is NostrResponse.Event -> { @@ -828,7 +843,7 @@ class NostrRelayManager private constructor() { subscriptionsToRestore.forEach { subscriptionInfo -> try { val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter)) - val message = gson.toJson(request, NostrRequest::class.java) + val message = NostrRequest.toJson(request) val success = webSocket.send(message) if (success) { diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRequest.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRequest.kt index 668e046e8..b669d1270 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRequest.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRequest.kt @@ -1,7 +1,7 @@ package com.bitchat.android.nostr -import com.google.gson.* -import java.lang.reflect.Type +import kotlinx.serialization.json.* +import com.bitchat.android.util.JsonUtil /** * Nostr protocol request messages @@ -27,54 +27,34 @@ sealed class NostrRequest { */ data class Close(val subscriptionId: String) : NostrRequest() - /** - * Custom JSON serializer for NostrRequest - */ - class RequestSerializer : JsonSerializer { - override fun serialize(src: NostrRequest, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { - val array = JsonArray() - - when (src) { - is Event -> { - array.add("EVENT") - array.add(context.serialize(src.event)) - } - - is Subscribe -> { - array.add("REQ") - array.add(src.subscriptionId) - src.filters.forEach { filter -> - array.add(context.serialize(filter, NostrFilter::class.java)) - } - } - - is Close -> { - array.add("CLOSE") - array.add(src.subscriptionId) - } - } - - return array - } - } - companion object { - /** - * Create Gson instance with proper serializers - */ - fun createGson(): Gson { - return GsonBuilder() - .registerTypeAdapter(NostrRequest::class.java, RequestSerializer()) - .registerTypeAdapter(NostrFilter::class.java, NostrFilter.FilterSerializer()) - .disableHtmlEscaping() - .create() - } - /** * Serialize request to JSON string */ fun toJson(request: NostrRequest): String { - return createGson().toJson(request) + val jsonArray = buildJsonArray { + when (request) { + is Event -> { + add("EVENT") + add(Json.encodeToJsonElement(request.event)) + } + + is Subscribe -> { + add("REQ") + add(request.subscriptionId) + request.filters.forEach { filter -> + add(filter.toJsonElement()) + } + } + + is Close -> { + add("CLOSE") + add(request.subscriptionId) + } + } + } + + return JsonUtil.json.encodeToString(JsonArray.serializer(), jsonArray) } } } @@ -129,11 +109,11 @@ sealed class NostrResponse { */ fun fromJsonArray(jsonArray: JsonArray): NostrResponse { return try { - when (val type = jsonArray[0].asString) { + when (val type = jsonArray[0].jsonPrimitive.content) { "EVENT" -> { - if (jsonArray.size() >= 3) { - val subscriptionId = jsonArray[1].asString - val eventJson = jsonArray[2].asJsonObject + if (jsonArray.size >= 3) { + val subscriptionId = jsonArray[1].jsonPrimitive.content + val eventJson = jsonArray[2].jsonObject val event = parseEventFromJson(eventJson) Event(subscriptionId, event) } else { @@ -142,8 +122,8 @@ sealed class NostrResponse { } "EOSE" -> { - if (jsonArray.size() >= 2) { - val subscriptionId = jsonArray[1].asString + if (jsonArray.size >= 2) { + val subscriptionId = jsonArray[1].jsonPrimitive.content EndOfStoredEvents(subscriptionId) } else { Unknown(jsonArray.toString()) @@ -151,11 +131,11 @@ sealed class NostrResponse { } "OK" -> { - if (jsonArray.size() >= 3) { - val eventId = jsonArray[1].asString - val accepted = jsonArray[2].asBoolean - val message = if (jsonArray.size() >= 4) { - jsonArray[3].asString + if (jsonArray.size >= 3) { + val eventId = jsonArray[1].jsonPrimitive.content + val accepted = jsonArray[2].jsonPrimitive.boolean + val message = if (jsonArray.size >= 4) { + jsonArray[3].jsonPrimitive.content } else null Ok(eventId, accepted, message) } else { @@ -164,8 +144,8 @@ sealed class NostrResponse { } "NOTICE" -> { - if (jsonArray.size() >= 2) { - val message = jsonArray[1].asString + if (jsonArray.size >= 2) { + val message = jsonArray[1].jsonPrimitive.content Notice(message) } else { Unknown(jsonArray.toString()) @@ -181,13 +161,13 @@ sealed class NostrResponse { private fun parseEventFromJson(jsonObject: JsonObject): NostrEvent { return NostrEvent( - id = jsonObject.get("id")?.asString ?: "", - pubkey = jsonObject.get("pubkey")?.asString ?: "", - createdAt = jsonObject.get("created_at")?.asInt ?: 0, - kind = jsonObject.get("kind")?.asInt ?: 0, - tags = parseTagsFromJson(jsonObject.get("tags")?.asJsonArray), - content = jsonObject.get("content")?.asString ?: "", - sig = jsonObject.get("sig")?.asString + id = jsonObject["id"]?.jsonPrimitive?.content ?: "", + pubkey = jsonObject["pubkey"]?.jsonPrimitive?.content ?: "", + createdAt = jsonObject["created_at"]?.jsonPrimitive?.int ?: 0, + kind = jsonObject["kind"]?.jsonPrimitive?.int ?: 0, + tags = parseTagsFromJson(jsonObject["tags"]?.jsonArray), + content = jsonObject["content"]?.jsonPrimitive?.content ?: "", + sig = jsonObject["sig"]?.jsonPrimitive?.content ) } @@ -196,9 +176,8 @@ sealed class NostrResponse { return try { tagsArray.map { tagElement -> - if (tagElement.isJsonArray) { - val tagArray = tagElement.asJsonArray - tagArray.map { it.asString } + if (tagElement is JsonArray) { + tagElement.map { it.jsonPrimitive.content } } else { emptyList() } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt index 54e6a5a72..076488f92 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -1,38 +1,36 @@ package com.bitchat.android.nostr -import android.app.Application import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import jakarta.inject.Inject /** * NostrSubscriptionManager * - Encapsulates subscription lifecycle with NostrRelayManager */ -class NostrSubscriptionManager( - private val application: Application, +class NostrSubscriptionManager @Inject constructor( + private val nostrRelayManager: NostrRelayManager, private val scope: CoroutineScope ) { companion object { private const val TAG = "NostrSubscriptionManager" } - private val relayManager get() = NostrRelayManager.getInstance(application) - - fun connect() = scope.launch { runCatching { relayManager.connect() }.onFailure { Log.e(TAG, "connect failed: ${it.message}") } } - fun disconnect() = scope.launch { runCatching { relayManager.disconnect() }.onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } } + fun connect() = scope.launch { runCatching { nostrRelayManager.connect() }.onFailure { Log.e(TAG, "connect failed: ${it.message}") } } + fun disconnect() = scope.launch { runCatching { nostrRelayManager.disconnect() }.onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } } fun subscribeGiftWraps(pubkey: String, sinceMs: Long, id: String, handler: (NostrEvent) -> Unit) { scope.launch { val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs) - relayManager.subscribe(filter, id, handler) + nostrRelayManager.subscribe(filter, id, handler) } } fun subscribeGeohash(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { scope.launch { val filter = NostrFilter.geohashEphemeral(geohash, sinceMs, limit) - relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5) + nostrRelayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5) } } - fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } } + fun unsubscribe(id: String) { scope.launch { runCatching { nostrRelayManager.unsubscribe(id) } } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt index 66f3b0957..d0f1e2d86 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTestManager.kt @@ -8,14 +8,16 @@ import kotlinx.coroutines.* * Test manager for Nostr functionality * Use this to verify the Nostr client works correctly */ -class NostrTestManager(private val context: Context) { +class NostrTestManager( + private val context: Context, + private val nostrClient: NostrClient +) { companion object { private const val TAG = "NostrTestManager" } private val testScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) - private lateinit var nostrClient: NostrClient /** * Run comprehensive Nostr tests @@ -54,7 +56,6 @@ class NostrTestManager(private val context: Context) { private suspend fun testClientInitialization() { Log.d(TAG, "Testing client initialization...") - nostrClient = NostrClient.getInstance(context) nostrClient.initialize() // Wait for initialization diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index 26ba83695..ff4a7b1aa 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -2,49 +2,48 @@ package com.bitchat.android.nostr import android.content.Context import android.util.Log +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.model.ReadReceipt import com.bitchat.android.model.NoisePayloadType +import jakarta.inject.Inject import kotlinx.coroutines.* import java.util.* import java.util.concurrent.ConcurrentLinkedQueue +import jakarta.inject.Singleton /** * Minimal Nostr transport for offline sending * Direct port from iOS NostrTransport for 100% compatibility */ -class NostrTransport( +@Singleton +class NostrTransport @Inject constructor( private val context: Context, - var senderPeerID: String = "" + private val nostrRelayManager: NostrRelayManager, + private val locationChannelManager: LocationChannelManager, + private val favoritesService: FavoritesPersistenceService ) { - + var senderPeerID: String = "" + companion object { private const val TAG = "NostrTransport" private const val READ_ACK_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.READ_ACK_INTERVAL_MS // ~3 per second (0.35s interval like iOS) - - @Volatile - private var INSTANCE: NostrTransport? = null - - fun getInstance(context: Context): NostrTransport { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: NostrTransport(context.applicationContext).also { INSTANCE = it } - } - } } - + // Throttle READ receipts to avoid relay rate limits (like iOS) private data class QueuedRead( val receipt: ReadReceipt, val peerID: String ) - + private val readQueue = ConcurrentLinkedQueue() private var isSendingReadAcks = false private val transportScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - + // MARK: - Transport Interface Methods - + val myPeerID: String get() = senderPeerID - + fun sendPrivateMessage( content: String, to: String, @@ -55,23 +54,23 @@ class NostrTransport( try { // Resolve favorite by full noise key or by short peerID fallback var recipientNostrPubkey: String? = null - + // Resolve by peerID first (new peerID→npub index), then fall back to noise key mapping recipientNostrPubkey = resolveNostrPublicKey(to) - + if (recipientNostrPubkey == null) { Log.w(TAG, "No Nostr public key found for peerID: $to") return@launch } - + val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context) if (senderIdentity == null) { Log.e(TAG, "No Nostr identity available") return@launch } - + Log.d(TAG, "NostrTransport: preparing PM to ${recipientNostrPubkey.take(16)}... for peerID ${to.take(8)}... id=${messageID.take(8)}...") - + // Convert recipient npub -> hex (x-only) val recipientHex = try { val (hrp, data) = Bech32.decode(recipientNostrPubkey) @@ -84,10 +83,10 @@ class NostrTransport( Log.e(TAG, "NostrTransport: failed to decode npub -> hex: $e") return@launch } - + // Strict: lookup the recipient's current BitChat peer ID using favorites mapping val recipientPeerIDForEmbed = try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared + favoritesService .findPeerIDForNostrPubkey(recipientNostrPubkey) } catch (_: Exception) { null } if (recipientPeerIDForEmbed.isNullOrBlank()) { @@ -100,73 +99,73 @@ class NostrTransport( recipientPeerID = recipientPeerIDForEmbed, senderPeerID = senderPeerID ) - - + + if (embedded == null) { Log.e(TAG, "NostrTransport: failed to embed PM packet") return@launch } - + val giftWraps = NostrProtocol.createPrivateMessage( content = embedded, recipientPubkey = recipientHex, senderIdentity = senderIdentity ) - + giftWraps.forEach { event -> Log.d(TAG, "NostrTransport: sending PM giftWrap id=${event.id.take(16)}...") - NostrRelayManager.getInstance(context).sendEvent(event) + nostrRelayManager.sendEvent(event) } - + } catch (e: Exception) { Log.e(TAG, "Failed to send private message via Nostr: ${e.message}") } } } - + fun sendReadReceipt(receipt: ReadReceipt, to: String) { // Enqueue and process with throttling to avoid relay rate limits readQueue.offer(QueuedRead(receipt, to)) processReadQueueIfNeeded() } - + private fun processReadQueueIfNeeded() { if (isSendingReadAcks) return if (readQueue.isEmpty()) return - + isSendingReadAcks = true sendNextReadAck() } - + private fun sendNextReadAck() { val item = readQueue.poll() if (item == null) { isSendingReadAcks = false return } - + transportScope.launch { try { var recipientNostrPubkey: String? = null - + // Try to resolve from favorites persistence service recipientNostrPubkey = resolveNostrPublicKey(item.peerID) - + if (recipientNostrPubkey == null) { Log.w(TAG, "No Nostr public key found for read receipt to: ${item.peerID}") scheduleNextReadAck() return@launch } - + val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context) if (senderIdentity == null) { Log.e(TAG, "No Nostr identity available for read receipt") scheduleNextReadAck() return@launch } - + Log.d(TAG, "NostrTransport: preparing READ ack for id=${item.receipt.originalMessageID.take(8)}... to ${recipientNostrPubkey.take(16)}...") - + // Convert recipient npub -> hex val recipientHex = try { val (hrp, data) = Bech32.decode(recipientNostrPubkey) @@ -179,40 +178,40 @@ class NostrTransport( scheduleNextReadAck() return@launch } - + val ack = NostrEmbeddedBitChat.encodeAckForNostr( type = NoisePayloadType.READ_RECEIPT, messageID = item.receipt.originalMessageID, recipientPeerID = item.peerID, senderPeerID = senderPeerID ) - + if (ack == null) { Log.e(TAG, "NostrTransport: failed to embed READ ack") scheduleNextReadAck() return@launch } - + val giftWraps = NostrProtocol.createPrivateMessage( content = ack, recipientPubkey = recipientHex, senderIdentity = senderIdentity ) - + giftWraps.forEach { event -> Log.d(TAG, "NostrTransport: sending READ ack giftWrap id=${event.id.take(16)}...") - NostrRelayManager.getInstance(context).sendEvent(event) + nostrRelayManager.sendEvent(event) } - + scheduleNextReadAck() - + } catch (e: Exception) { Log.e(TAG, "Failed to send read receipt via Nostr: ${e.message}") scheduleNextReadAck() } } } - + private fun scheduleNextReadAck() { transportScope.launch { delay(READ_ACK_INTERVAL) @@ -220,30 +219,30 @@ class NostrTransport( processReadQueueIfNeeded() } } - + fun sendFavoriteNotification(to: String, isFavorite: Boolean) { transportScope.launch { try { var recipientNostrPubkey: String? = null - + // Try to resolve from favorites persistence service recipientNostrPubkey = resolveNostrPublicKey(to) - + if (recipientNostrPubkey == null) { Log.w(TAG, "No Nostr public key found for favorite notification to: $to") return@launch } - + val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context) if (senderIdentity == null) { Log.e(TAG, "No Nostr identity available for favorite notification") return@launch } - + val content = if (isFavorite) "[FAVORITED]:${senderIdentity.npub}" else "[UNFAVORITED]:${senderIdentity.npub}" - + Log.d(TAG, "NostrTransport: preparing FAVORITE($isFavorite) to ${recipientNostrPubkey.take(16)}...") - + // Convert recipient npub -> hex val recipientHex = try { val (hrp, data) = Bech32.decode(recipientNostrPubkey) @@ -252,57 +251,57 @@ class NostrTransport( } catch (e: Exception) { return@launch } - + val embedded = NostrEmbeddedBitChat.encodePMForNostr( content = content, messageID = UUID.randomUUID().toString(), recipientPeerID = to, senderPeerID = senderPeerID ) - + if (embedded == null) { Log.e(TAG, "NostrTransport: failed to embed favorite notification") return@launch } - + val giftWraps = NostrProtocol.createPrivateMessage( content = embedded, recipientPubkey = recipientHex, senderIdentity = senderIdentity ) - + giftWraps.forEach { event -> Log.d(TAG, "NostrTransport: sending favorite giftWrap id=${event.id.take(16)}...") - NostrRelayManager.getInstance(context).sendEvent(event) + nostrRelayManager.sendEvent(event) } - + } catch (e: Exception) { Log.e(TAG, "Failed to send favorite notification via Nostr: ${e.message}") } } } - + fun sendDeliveryAck(messageID: String, to: String) { transportScope.launch { try { var recipientNostrPubkey: String? = null - + // Try to resolve from favorites persistence service recipientNostrPubkey = resolveNostrPublicKey(to) - + if (recipientNostrPubkey == null) { Log.w(TAG, "No Nostr public key found for delivery ack to: $to") return@launch } - + val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context) if (senderIdentity == null) { Log.e(TAG, "No Nostr identity available for delivery ack") return@launch } - + Log.d(TAG, "NostrTransport: preparing DELIVERED ack for id=${messageID.take(8)}... to ${recipientNostrPubkey.take(16)}...") - + val recipientHex = try { val (hrp, data) = Bech32.decode(recipientNostrPubkey) if (hrp != "npub") return@launch @@ -310,38 +309,38 @@ class NostrTransport( } catch (e: Exception) { return@launch } - + val ack = NostrEmbeddedBitChat.encodeAckForNostr( type = NoisePayloadType.DELIVERED, messageID = messageID, recipientPeerID = to, senderPeerID = senderPeerID ) - + if (ack == null) { Log.e(TAG, "NostrTransport: failed to embed DELIVERED ack") return@launch } - + val giftWraps = NostrProtocol.createPrivateMessage( content = ack, recipientPubkey = recipientHex, senderIdentity = senderIdentity ) - + giftWraps.forEach { event -> Log.d(TAG, "NostrTransport: sending DELIVERED ack giftWrap id=${event.id.take(16)}...") - NostrRelayManager.getInstance(context).sendEvent(event) + nostrRelayManager.sendEvent(event) } - + } catch (e: Exception) { Log.e(TAG, "Failed to send delivery ack via Nostr: ${e.message}") } } } - + // MARK: - Geohash ACK helpers (for per-geohash identity DMs) - + fun sendDeliveryAckGeohash( messageID: String, toRecipientHex: String, @@ -350,33 +349,33 @@ class NostrTransport( transportScope.launch { try { Log.d(TAG, "GeoDM: send DELIVERED -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...") - + val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient( type = NoisePayloadType.DELIVERED, messageID = messageID, senderPeerID = senderPeerID ) - + if (embedded == null) return@launch - + val giftWraps = NostrProtocol.createPrivateMessage( content = embedded, recipientPubkey = toRecipientHex, senderIdentity = fromIdentity ) - + // Register pending gift wrap for deduplication and send all giftWraps.forEach { event -> - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) + nostrRelayManager.registerPendingGiftWrap(event.id) + nostrRelayManager.sendEvent(event) } - + } catch (e: Exception) { Log.e(TAG, "Failed to send geohash delivery ack: ${e.message}") } } } - + fun sendReadReceiptGeohash( messageID: String, toRecipientHex: String, @@ -385,35 +384,35 @@ class NostrTransport( transportScope.launch { try { Log.d(TAG, "GeoDM: send READ -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...") - + val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient( type = NoisePayloadType.READ_RECEIPT, messageID = messageID, senderPeerID = senderPeerID ) - + if (embedded == null) return@launch - + val giftWraps = NostrProtocol.createPrivateMessage( content = embedded, recipientPubkey = toRecipientHex, senderIdentity = fromIdentity ) - + // Register pending gift wrap for deduplication and send all giftWraps.forEach { event -> - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) + nostrRelayManager.registerPendingGiftWrap(event.id) + nostrRelayManager.sendEvent(event) } - + } catch (e: Exception) { Log.e(TAG, "Failed to send geohash read receipt: ${e.message}") } } } - + // MARK: - Geohash DMs (per-geohash identity) - + fun sendPrivateMessageGeohash( content: String, toRecipientHex: String, @@ -423,7 +422,7 @@ class NostrTransport( // Use provided geohash or derive from current location val geohash = sourceGeohash ?: run { val selected = try { - com.bitchat.android.geohash.LocationChannelManager.getInstance(context).selectedChannel.value + locationChannelManager.selectedChannel.value } catch (_: Exception) { null } if (selected !is com.bitchat.android.geohash.ChannelID.Location) { Log.w(TAG, "NostrTransport: cannot send geohash PM - not in a location channel and no geohash provided") @@ -431,14 +430,14 @@ class NostrTransport( } selected.channel.geohash } - + val fromIdentity = try { NostrIdentityBridge.deriveIdentity(geohash, context) } catch (e: Exception) { Log.e(TAG, "NostrTransport: cannot derive geohash identity for $geohash: ${e.message}") return } - + transportScope.launch { try { if (toRecipientHex.isEmpty()) return@launch @@ -466,43 +465,43 @@ class NostrTransport( giftWraps.forEach { event -> Log.d(TAG, "NostrTransport: sending geohash PM giftWrap id=${event.id.take(16)}...") - NostrRelayManager.registerPendingGiftWrap(event.id) - NostrRelayManager.getInstance(context).sendEvent(event) + nostrRelayManager.registerPendingGiftWrap(event.id) + nostrRelayManager.sendEvent(event) } } catch (e: Exception) { Log.e(TAG, "Failed to send geohash private message: ${e.message}") } } } - + // MARK: - Helper Methods - + /** * Resolve Nostr public key for a peer ID */ private fun resolveNostrPublicKey(peerID: String): String? { try { // 1) Fast path: direct peerID→npub mapping (mutual favorites after mesh mapping) - com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(peerID)?.let { return it } + favoritesService.findNostrPubkeyForPeerID(peerID)?.let { return it } // 2) Legacy path: resolve by noise public key association val noiseKey = hexStringToByteArray(peerID) - val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) + val favoriteStatus = favoritesService.getFavoriteStatus(noiseKey) if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey // 3) Prefix match on noiseHex from 16-hex peerID if (peerID.length == 16) { - val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + val fallbackStatus = favoritesService.getFavoriteStatus(peerID) return fallbackStatus?.peerNostrPublicKey } - + return null } catch (e: Exception) { Log.e(TAG, "Failed to resolve Nostr public key for $peerID: ${e.message}") return null } } - + /** * Convert full hex string to byte array */ @@ -510,7 +509,7 @@ class NostrTransport( val clean = if (hexString.length % 2 == 0) hexString else "0$hexString" return clean.chunked(2).map { it.toInt(16).toByte() }.toByteArray() } - + fun cleanup() { transportScope.cancel() } 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 3a3042fea..7d0154d00 100644 --- a/app/src/main/java/com/bitchat/android/nostr/PoWPreferenceManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/PoWPreferenceManager.kt @@ -2,6 +2,8 @@ package com.bitchat.android.nostr import android.content.Context import android.content.SharedPreferences +import jakarta.inject.Inject +import jakarta.inject.Singleton import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -9,15 +11,18 @@ import kotlinx.coroutines.flow.asStateFlow /** * Manages Proof of Work preferences for Nostr events */ -object PoWPreferenceManager { +@Singleton +class PoWPreferenceManager @Inject constructor(context: Context) { - private const val PREFS_NAME = "pow_preferences" - private const val KEY_POW_ENABLED = "pow_enabled" - private const val KEY_POW_DIFFICULTY = "pow_difficulty" - - // Default values - private const val DEFAULT_POW_ENABLED = false - private const val DEFAULT_POW_DIFFICULTY = 12 // Reasonable default for geohash spam prevention + private companion object { + const val PREFS_NAME = "pow_preferences" + const val KEY_POW_ENABLED = "pow_enabled" + const val KEY_POW_DIFFICULTY = "pow_difficulty" + + // Default values + const val DEFAULT_POW_ENABLED = false + const val DEFAULT_POW_DIFFICULTY = 12 // Reasonable default for geohash spam prevention + } // State flows for reactive UI private val _powEnabled = MutableStateFlow(DEFAULT_POW_ENABLED) @@ -30,23 +35,12 @@ object PoWPreferenceManager { private val _isMining = MutableStateFlow(false) val isMining: StateFlow = _isMining.asStateFlow() - private lateinit var sharedPrefs: SharedPreferences - private var isInitialized = false + private val sharedPrefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - /** - * Initialize the preference manager with application context - * Should be called once during app startup - */ - fun init(context: Context) { - if (isInitialized) return - - sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - + init { // Load current values _powEnabled.value = sharedPrefs.getBoolean(KEY_POW_ENABLED, DEFAULT_POW_ENABLED) _powDifficulty.value = sharedPrefs.getInt(KEY_POW_DIFFICULTY, DEFAULT_POW_DIFFICULTY) - - isInitialized = true } /** @@ -61,9 +55,7 @@ object PoWPreferenceManager { */ fun setPowEnabled(enabled: Boolean) { _powEnabled.value = enabled - if (::sharedPrefs.isInitialized) { - sharedPrefs.edit().putBoolean(KEY_POW_ENABLED, enabled).apply() - } + sharedPrefs.edit().putBoolean(KEY_POW_ENABLED, enabled).apply() } /** @@ -79,9 +71,7 @@ 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() - } + sharedPrefs.edit().putInt(KEY_POW_DIFFICULTY, clampedDifficulty).apply() } /** diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt index f591b2b89..5013ec21a 100644 --- a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -3,6 +3,16 @@ package com.bitchat.android.nostr import android.app.Application import android.content.SharedPreferences import android.util.Log +import com.bitchat.android.net.OkHttpProvider +import jakarta.inject.Inject +import jakarta.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request import java.io.BufferedReader import java.io.File import java.io.FileInputStream @@ -11,31 +21,34 @@ import java.io.InputStream import java.io.InputStreamReader import java.security.MessageDigest import java.util.concurrent.TimeUnit -import kotlin.math.* -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import okhttp3.OkHttpClient -import okhttp3.Request +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt /** * Loads relay coordinates from assets and provides nearest-relay lookup by geohash. */ -object RelayDirectory { - - private const val TAG = "RelayDirectory" - private const val ASSET_FILE_URL = "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv" - private const val ASSET_FILE = "nostr_relays.csv" - private const val DOWNLOADED_FILE = "nostr_relays_latest.csv" - private const val PREFS_NAME = "relay_directory_prefs" - private const val KEY_LAST_UPDATE_MS = "last_update_ms" - private val ONE_DAY_MS = TimeUnit.DAYS.toMillis(1) +@Singleton +class RelayDirectory @Inject constructor( + private val application: Application, + private val okHttpProvider: OkHttpProvider +) { + + companion object{ + private const val TAG = "RelayDirectory" + private const val ASSET_FILE_URL = "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv" + private const val ASSET_FILE = "nostr_relays.csv" + private const val DOWNLOADED_FILE = "nostr_relays_latest.csv" + private const val PREFS_NAME = "relay_directory_prefs" + private const val KEY_LAST_UPDATE_MS = "last_update_ms" + private val ONE_DAY_MS = TimeUnit.DAYS.toMillis(1) + } private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val httpClient: OkHttpClient - get() = com.bitchat.android.net.OkHttpProvider.httpClient() + get() = okHttpProvider.httpClient() data class RelayInfo( val url: String, @@ -49,7 +62,11 @@ object RelayDirectory { private val relays: MutableList = mutableListOf() private val relaysLock = Any() - fun initialize(application: Application) { + init { + initialize() + } + + private fun initialize() { if (initialized) return synchronized(this) { if (initialized) return 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 ff0a160fd..5d458f7a0 100644 --- a/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt @@ -7,12 +7,15 @@ import android.os.Build import android.os.PowerManager import android.util.Log import androidx.core.content.ContextCompat +import jakarta.inject.Inject +import jakarta.inject.Singleton /** * Centralized permission management for bitchat app * Handles all Bluetooth and notification permissions required for the app to function */ -class PermissionManager(private val context: Context) { +@Singleton +class PermissionManager @Inject constructor(private val context: Context) { companion object { private const val TAG = "PermissionManager" diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 2d15b86e4..81b959e88 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -1,10 +1,12 @@ package com.bitchat.android.protocol -import android.os.Parcelable -import kotlinx.parcelize.Parcelize import java.nio.ByteBuffer import java.nio.ByteOrder import android.util.Log +import kotlinx.serialization.Serializable +import com.bitchat.android.util.ByteArraySerializer +import com.bitchat.android.util.UByteSerializer +import com.bitchat.android.util.ULongSerializer /** * Message types - exact same as iOS version with Noise Protocol support @@ -50,17 +52,17 @@ object SpecialRecipients { * - Payload: Variable length (includes original size if compressed) * - Signature: 64 bytes (if hasSignature flag set) */ -@Parcelize +@Serializable data class BitchatPacket( - val version: UByte = 1u, - val type: UByte, - val senderID: ByteArray, - val recipientID: ByteArray? = null, - val timestamp: ULong, - val payload: ByteArray, - var signature: ByteArray? = null, // Changed from val to var for packet signing - var ttl: UByte -) : Parcelable { + @Serializable(with = UByteSerializer::class) val version: UByte = 1u, + @Serializable(with = UByteSerializer::class) val type: UByte, + @Serializable(with = ByteArraySerializer::class) val senderID: ByteArray, + @Serializable(with = ByteArraySerializer::class) val recipientID: ByteArray? = null, + @Serializable(with = ULongSerializer::class) val timestamp: ULong, + @Serializable(with = ByteArraySerializer::class) val payload: ByteArray, + @Serializable(with = ByteArraySerializer::class) var signature: ByteArray? = null, // Changed from val to var for packet signing + @Serializable(with = UByteSerializer::class) var ttl: UByte +) { constructor( type: UByte, diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index 8166487e4..936c3f3b8 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -2,40 +2,25 @@ package com.bitchat.android.services import android.content.Context import android.util.Log +import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.ReadReceipt import com.bitchat.android.nostr.NostrTransport +import jakarta.inject.Inject +import jakarta.inject.Singleton /** * Routes messages between BLE mesh and Nostr transports, matching iOS behavior. */ -class MessageRouter private constructor( +@Singleton +class MessageRouter @Inject constructor( private val context: Context, private val mesh: BluetoothMeshService, - private val nostr: NostrTransport + private val nostr: NostrTransport, + private val favoritesService: FavoritesPersistenceService ) { companion object { private const val TAG = "MessageRouter" - @Volatile private var INSTANCE: MessageRouter? = null - fun tryGetInstance(): MessageRouter? = INSTANCE - fun getInstance(context: Context, mesh: BluetoothMeshService): MessageRouter { - return INSTANCE ?: synchronized(this) { - val nostr = NostrTransport.getInstance(context) - INSTANCE?.also { - // Update mesh reference if needed and keep senderPeerID in sync - it.nostr.senderPeerID = mesh.myPeerID - return it - } - MessageRouter(context.applicationContext, mesh, nostr).also { instance -> - instance.nostr.senderPeerID = mesh.myPeerID - // Register for favorites changes to flush outbox - try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener) - } catch (_: Exception) {} - INSTANCE = instance - } - } - } } // Outbox: peerID -> queued (content, nickname, messageID) @@ -55,6 +40,14 @@ class MessageRouter private constructor( } } + init { + nostr.senderPeerID = mesh.myPeerID + // Register for favorites changes to flush outbox + try { + favoritesService.addListener(favoriteListener) + } catch (_: Exception) {} + } + fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String) { // First: if this is a geohash DM alias (nostr_), route via Nostr using global registry if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) { @@ -165,11 +158,11 @@ class MessageRouter private constructor( // Full Noise key hex if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { val noiseKey = hexToBytes(peerID) - val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) + val fav = favoritesService.getFavoriteStatus(noiseKey) fav?.isMutual == true && fav.peerNostrPublicKey != null } else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { // Ephemeral 16-hex mesh ID: resolve via prefix match in favorites - val fav = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID) + val fav = favoritesService.getFavoriteStatus(peerID) fav?.isMutual == true && fav.peerNostrPublicKey != null } else { false 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 a6cd23784..8c3dd8b5d 100644 --- a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt +++ b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt @@ -1,30 +1,26 @@ 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 kotlinx.serialization.Serializable +import com.bitchat.android.util.JsonUtil +import jakarta.inject.Inject +import jakarta.inject.Singleton /** * Persistent store for message IDs we've already acknowledged (DELIVERED) or READ. * Limits to last MAX_IDS entries per set to avoid memory bloat. */ -class SeenMessageStore private constructor(private val context: Context) { +@Singleton +class SeenMessageStore @Inject constructor( + private val secure: SecureIdentityStateManager +) { companion object { private const val TAG = "SeenMessageStore" private const val STORAGE_KEY = "seen_message_store_v1" private const val MAX_IDS = com.bitchat.android.util.AppConstants.Services.SEEN_MESSAGE_MAX_IDS - - @Volatile private var INSTANCE: SeenMessageStore? = null - fun getInstance(appContext: Context): SeenMessageStore { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: SeenMessageStore(appContext.applicationContext).also { INSTANCE = it } - } - } } - private val gson = Gson() - private val secure = SecureIdentityStateManager(context) private val delivered = LinkedHashSet(MAX_IDS) private val read = LinkedHashSet(MAX_IDS) @@ -61,7 +57,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 = JsonUtil.fromJsonOrNull(json) ?: return delivered.clear(); read.clear() data.delivered.takeLast(MAX_IDS).forEach { delivered.add(it) } data.read.takeLast(MAX_IDS).forEach { read.add(it) } @@ -74,13 +70,14 @@ 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) + val json = JsonUtil.toJson(payload) secure.storeSecureValue(STORAGE_KEY, json) } catch (e: Exception) { Log.e(TAG, "Failed to persist SeenMessageStore: ${e.message}") } } + @Serializable private data class StorePayload( val delivered: List = emptyList(), val read: List = emptyList() diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 091cf630e..ba5391397 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Security @@ -31,6 +32,9 @@ import com.bitchat.android.nostr.PoWPreferenceManager import com.bitchat.android.ui.debug.DebugSettingsSheet import androidx.compose.ui.res.stringResource import com.bitchat.android.R +import com.bitchat.android.net.TorMode +import com.bitchat.android.ui.theme.ThemePreference + /** * About Sheet for bitchat app information * Matches the design language of LocationChannelsSheet @@ -40,6 +44,7 @@ import com.bitchat.android.R fun AboutSheet( isPresented: Boolean, onDismiss: () -> Unit, + viewModel: ChatViewModel, onShowDebug: (() -> Unit)? = null, modifier: Modifier = Modifier ) { @@ -240,24 +245,24 @@ fun AboutSheet( .padding(horizontal = 24.dp) .padding(top = 24.dp, bottom = 8.dp) ) - val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() + val themePref by viewModel.themePreference.collectAsState() Row( modifier = Modifier.padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { FilterChip( selected = themePref.isSystem, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, + onClick = { viewModel.setTheme(ThemePreference.System) }, label = { Text(stringResource(R.string.about_system), fontFamily = FontFamily.Monospace) } ) FilterChip( selected = themePref.isLight, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, + onClick = { viewModel.setTheme(ThemePreference.Light) }, label = { Text(stringResource(R.string.about_light), fontFamily = FontFamily.Monospace) } ) FilterChip( selected = themePref.isDark, - onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, + onClick = { viewModel.setTheme(ThemePreference.Dark) }, label = { Text(stringResource(R.string.about_dark), fontFamily = FontFamily.Monospace) } ) } @@ -272,12 +277,10 @@ fun AboutSheet( .padding(horizontal = 24.dp) .padding(top = 24.dp, bottom = 8.dp) ) - LaunchedEffect(Unit) { - PoWPreferenceManager.init(context) - } - - val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() - val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() + + + val powEnabled by viewModel.powEnabled.collectAsState() + val powDifficulty by viewModel.powDifficulty.collectAsState() Column( modifier = Modifier.padding(horizontal = 24.dp), @@ -289,12 +292,12 @@ fun AboutSheet( ) { FilterChip( selected = !powEnabled, - onClick = { PoWPreferenceManager.setPowEnabled(false) }, + onClick = { viewModel.setPowEnabled(false) }, label = { Text(stringResource(R.string.about_pow_off), fontFamily = FontFamily.Monospace) } ) FilterChip( selected = powEnabled, - onClick = { PoWPreferenceManager.setPowEnabled(true) }, + onClick = { viewModel.setPowEnabled(true) }, label = { Row( horizontalArrangement = Arrangement.spacedBy(6.dp), @@ -334,7 +337,7 @@ fun AboutSheet( Slider( value = powDifficulty.toFloat(), - onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, + onValueChange = { viewModel.setPowDifficulty(it.toInt()) }, valueRange = 0f..32f, steps = 33, colors = SliderDefaults.colors( @@ -382,8 +385,9 @@ fun AboutSheet( // Network (Tor) section item(key = "network_section") { - val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(context)) } - val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState() + val torStatus by viewModel.torStatus.collectAsState() + val torMode = torStatus.mode + Text( text = stringResource(R.string.about_network), style = MaterialTheme.typography.labelLarge, @@ -398,18 +402,16 @@ fun AboutSheet( verticalAlignment = Alignment.CenterVertically ) { FilterChip( - selected = torMode.value == com.bitchat.android.net.TorMode.OFF, + selected = torMode == TorMode.OFF, onClick = { - torMode.value = com.bitchat.android.net.TorMode.OFF - com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value) + viewModel.setTorMode(TorMode.OFF) }, label = { Text("tor off", fontFamily = FontFamily.Monospace) } ) FilterChip( - selected = torMode.value == com.bitchat.android.net.TorMode.ON, + selected = torMode == TorMode.ON, onClick = { - torMode.value = com.bitchat.android.net.TorMode.ON - com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value) + viewModel.setTorMode(TorMode.ON) }, label = { Row( @@ -434,7 +436,7 @@ fun AboutSheet( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) - if (torMode.value == com.bitchat.android.net.TorMode.ON) { + if (torMode == TorMode.ON) { val statusText = if (torStatus.running) "Running" else "Stopped" // Debug status (temporary) Surface( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 4b1b22ca8..bf8261ae8 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -57,9 +57,10 @@ fun isFavoriteReactive( @Composable fun TorStatusDot( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + viewModel: ChatViewModel, ) { - val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState() + val torStatus by viewModel.torStatus.collectAsState() if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) { val dotColor = when { @@ -324,9 +325,9 @@ private fun PrivateChatHeader( if (isNostrDM) return@remember false if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.isMutual == true + viewModel.getFavoriteStatus(noiseKeyBytes)?.isMutual == true } else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.isMutual == true + viewModel.getFavoriteStatus(peerID)?.isMutual == true } else false } catch (_: Exception) { false } } @@ -357,9 +358,9 @@ private fun PrivateChatHeader( val titleFromFavorites = try { if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { val noiseKeyBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKeyBytes)?.peerNickname + viewModel.getFavoriteStatus(noiseKeyBytes)?.peerNickname } else if (peerID.length == 16 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.peerNickname + viewModel.getFavoriteStatus(peerID)?.peerNickname } else null } catch (_: Exception) { null } titleFromFavorites ?: peerID.take(12) @@ -532,9 +533,7 @@ private fun MainHeader( val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) // Bookmarks store for current geohash toggle (iOS parity) - val context = androidx.compose.ui.platform.LocalContext.current - val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) } - val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList()) + val bookmarks by viewModel.geohashBookmarks.observeAsState(emptyList()) Row( modifier = Modifier.fillMaxWidth(), @@ -600,7 +599,7 @@ private fun MainHeader( modifier = Modifier .padding(start = 2.dp) // minimal gap between geohash and bookmark .size(20.dp) - .clickable { bookmarksStore.toggle(currentGeohash) }, + .clickable { viewModel.toggleGeohashBookmark(currentGeohash) }, contentAlignment = Alignment.Center ) { Icon( @@ -623,13 +622,15 @@ private fun MainHeader( TorStatusDot( modifier = Modifier .size(8.dp) - .padding(start = 0.dp, end = 2.dp) + .padding(start = 0.dp, end = 2.dp), + viewModel = viewModel ) // PoW status indicator PoWStatusIndicator( modifier = Modifier, - style = PoWIndicatorStyle.COMPACT + style = PoWIndicatorStyle.COMPACT, + viewModel = viewModel ) Spacer(modifier = Modifier.width(2.dp)) PeerCounter( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 6ec8bef7e..3e971204a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -179,6 +179,9 @@ fun ChatScreen(viewModel: ChatViewModel) { viewerImagePaths = allImagePaths initialViewerIndex = initialIndex showFullScreenImageViewer = true + }, + onGeohashClick = { geohash -> + viewModel.teleportToGeohash(geohash) } ) // Input area - stays at bottom @@ -452,9 +455,6 @@ private fun ChatFloatingHeader( onLocationChannelsClick: () -> Unit, onLocationNotesClick: () -> Unit ) { - val context = androidx.compose.ui.platform.LocalContext.current - val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) } - Surface( modifier = Modifier .fillMaxWidth() @@ -481,7 +481,7 @@ private fun ChatFloatingHeader( onLocationChannelsClick = onLocationChannelsClick, onLocationNotesClick = { // Ensure location is loaded before showing sheet - locationManager.refreshChannels() + viewModel.refreshLocationChannels() onLocationNotesClick() } ) @@ -530,6 +530,7 @@ private fun ChatDialogs( AboutSheet( isPresented = showAppInfo, onDismiss = onAppInfoDismiss, + viewModel = viewModel, onShowDebug = { showDebugSheet = true } ) if (showDebugSheet) { 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 74ab15c67..0187e56b4 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1,22 +1,34 @@ package com.bitchat.android.ui + import android.app.Application import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope +import com.bitchat.android.favorites.FavoritesPersistenceService +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.model.BitchatMessageType -import com.bitchat.android.protocol.BitchatPacket - - -import kotlinx.coroutines.launch +import com.bitchat.android.net.TorManager +import com.bitchat.android.net.TorMode +import com.bitchat.android.net.TorPreferenceManager +import com.bitchat.android.nostr.LocationNotesManager +import com.bitchat.android.nostr.NostrRelayManager +import com.bitchat.android.nostr.NostrTransport +import com.bitchat.android.nostr.PoWPreferenceManager +import com.bitchat.android.services.MessageRouter +import com.bitchat.android.ui.debug.DebugSettingsManager +import com.bitchat.android.ui.theme.ThemePreference +import com.bitchat.android.ui.theme.ThemePreferenceManager import com.bitchat.android.util.NotificationIntervalManager +import jakarta.inject.Inject import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel import java.util.Date import kotlin.random.Random @@ -24,11 +36,23 @@ import kotlin.random.Random * Refactored ChatViewModel - Main coordinator for bitchat functionality * Delegates specific responsibilities to specialized managers while maintaining 100% iOS compatibility */ -class ChatViewModel( +@KoinViewModel +class ChatViewModel @Inject constructor( application: Application, - val meshService: BluetoothMeshService + val meshService: BluetoothMeshService, + private val nostrRelayManager: NostrRelayManager, + private val nostrTransport: NostrTransport, + private val messageRouter: MessageRouter, + private val seenStore: com.bitchat.android.services.SeenMessageStore, + private val fingerprintManager: com.bitchat.android.mesh.PeerFingerprintManager, + private val geohashBookmarksStore: com.bitchat.android.geohash.GeohashBookmarksStore, + private val debugManager: DebugSettingsManager, + private val locationChannelManager: LocationChannelManager, + private val poWPreferenceManager: PoWPreferenceManager, + val favoritesService: FavoritesPersistenceService, + private val locationNotesManager: LocationNotesManager, + private val torManager: TorManager, ) : AndroidViewModel(application), BluetoothMeshDelegate { - private val debugManager by lazy { try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() } catch (e: Exception) { null } } companion object { private const val TAG = "ChatViewModel" @@ -65,7 +89,7 @@ class ChatViewModel( override fun getMyPeerID(): String = meshService.myPeerID } - val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate) + val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate, fingerprintManager, favoritesService) private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager) private val notificationManager = NotificationManager( application.applicationContext, @@ -86,9 +110,11 @@ class ChatViewModel( coroutineScope = viewModelScope, onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) }, getMyPeerID = { meshService.myPeerID }, - getMeshService = { meshService } + getMeshService = { meshService }, + messageRouter = messageRouter, + favoritesService = favoritesService ) - + // New Geohash architecture ViewModel (replaces God object service usage in UI path) val geohashViewModel = GeohashViewModel( application = application, @@ -97,7 +123,12 @@ class ChatViewModel( privateChatManager = privateChatManager, meshDelegateHandler = meshDelegateHandler, dataManager = dataManager, - notificationManager = notificationManager + notificationManager = notificationManager, + nostrRelayManager = nostrRelayManager, + nostrTransport = nostrTransport, + seenStore = seenStore, + locationChannelManager = locationChannelManager, + powPreferenceManager = poWPreferenceManager ) @@ -138,6 +169,30 @@ class ChatViewModel( val teleportedGeo: LiveData> = state.teleportedGeo val geohashParticipantCounts: LiveData> = state.geohashParticipantCounts + // Location Notes + val locationNotes: LiveData> = locationNotesManager.notes + val locationNotesState: LiveData = locationNotesManager.state + val locationNotesErrorMessage = locationNotesManager.errorMessage + val locationNotesInitialLoadComplete = locationNotesManager.initialLoadComplete + + // Location Channel + val locationPermissionState = locationChannelManager.permissionState + val locationServicesEnabled = locationChannelManager.locationServicesEnabled + val availableLocationChannels = locationChannelManager.availableChannels + val locationNames = locationChannelManager.locationNames + + // Bookmarks + val geohashBookmarks: LiveData> = geohashBookmarksStore.bookmarks + val geohashBookmarkNames: LiveData> = geohashBookmarksStore.bookmarkNames + + // Tor + val torStatus = torManager.statusFlow + + // PoW + val powEnabled = poWPreferenceManager.powEnabled + val powDifficulty = poWPreferenceManager.powDifficulty + val isMining = poWPreferenceManager.isMining + init { // Note: Mesh service delegate is now set by MainActivity loadAndInitialize() @@ -190,8 +245,8 @@ class ChatViewModel( // Bridge DebugSettingsManager -> Chat messages when verbose logging is on viewModelScope.launch { - com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().debugMessages.collect { msgs -> - if (com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().verboseLoggingEnabled.value) { + debugManager.debugMessages.collect { msgs -> + if (debugManager.verboseLoggingEnabled.value) { // Only show debug logs in the Mesh chat timeline to avoid leaking into geohash chats val selectedLocation = state.selectedLocationChannel.value if (selectedLocation is com.bitchat.android.geohash.ChannelID.Mesh) { @@ -207,13 +262,11 @@ class ChatViewModel( // Initialize new geohash architecture geohashViewModel.initialize() - // Initialize favorites persistence service - com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) + // Ensure NostrTransport knows our mesh peer ID for embedded packets try { - val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication()) nostrTransport.senderPeerID = meshService.myPeerID } catch (_: Exception) { } @@ -308,7 +361,7 @@ class ChatViewModel( // Persistently mark all messages in this conversation as read so Nostr fetches // after app restarts won't re-mark them as unread. try { - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) + val seen = seenStore val chats = state.getPrivateChatsValue() val messages = chats[peerID] ?: emptyList() messages.forEach { msg -> @@ -367,7 +420,7 @@ class ChatViewModel( meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey }, meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true }, nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) }, - findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } + findNoiseKeyForNostr = { key -> favoritesService.findNoiseKey(key) } ) canonical ?: targetKey } @@ -426,7 +479,7 @@ class ChatViewModel( meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey }, meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true }, nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) }, - findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } + findNoiseKeyForNostr = { key -> favoritesService.findNoiseKey(key) } ).also { canonical -> if (canonical != state.getSelectedPrivateChatPeerValue()) { privateChatManager.startPrivateChat(canonical, meshService) @@ -442,8 +495,7 @@ class ChatViewModel( meshService.myPeerID ) { messageContent, peerID, recipientNicknameParam, messageId -> // Route via MessageRouter (mesh when connected+established, else Nostr) - val router = com.bitchat.android.services.MessageRouter.getInstance(getApplication(), meshService) - router.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId) + messageRouter.sendPrivate(messageContent, peerID, recipientNicknameParam, messageId) } } else { // Check if we're in a location channel @@ -519,7 +571,7 @@ class ChatViewModel( try { noiseKey = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() // Prefer nickname from favorites store if available - val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey!!) + val rel = favoritesService.getFavoriteStatus(noiseKey!!) if (rel != null) nickname = rel.peerNickname } catch (_: Exception) { } } @@ -531,7 +583,7 @@ class ChatViewModel( val fingerprint = identityManager.generateFingerprint(noiseKey!!) val isNowFavorite = dataManager.favoritePeers.contains(fingerprint) - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus( + favoritesService.updateFavoriteStatus( noisePublicKey = noiseKey!!, nickname = nickname, isFavorite = isNowFavorite @@ -551,7 +603,6 @@ class ChatViewModel( java.util.UUID.randomUUID().toString() ) } else { - val nostrTransport = com.bitchat.android.nostr.NostrTransport.getInstance(getApplication()) nostrTransport.senderPeerID = meshService.myPeerID nostrTransport.sendFavoriteNotification(peerID, isNowFavorite) } @@ -601,9 +652,7 @@ class ChatViewModel( sessionStates.forEach { (peerID, newState) -> val old = prevStates[peerID] if (old != "established" && newState == "established") { - com.bitchat.android.services.MessageRouter - .getInstance(getApplication(), meshService) - .onSessionEstablished(peerID) + messageRouter.onSessionEstablished(peerID) } } // Update fingerprint mappings from centralized manager @@ -746,8 +795,7 @@ class ChatViewModel( try { // Clear geohash bookmarks too (panic should remove everything) try { - val store = com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication()) - store.clearAll() + geohashBookmarksStore.clearAll() } catch (_: Exception) { } geohashViewModel.panicReset() @@ -779,6 +827,77 @@ class ChatViewModel( Log.e(TAG, "❌ Error clearing mesh service data: ${e.message}") } } + + // MARK: - Location & Network Management + + fun refreshLocationChannels() { + locationChannelManager.refreshChannels() + } + + fun enableLocationChannels() = locationChannelManager.enableLocationChannels() + fun enableLocationServices() = locationChannelManager.enableLocationServices() + fun disableLocationServices() = locationChannelManager.disableLocationServices() + fun selectLocationChannel(channel: ChannelID): Unit = locationChannelManager.select(channel) + + /** + * Teleport to a specific geohash by parsing its level + */ + fun teleportToGeohash(geohash: String) { + try { + val level = when (geohash.length) { + in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION + in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE + 5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY + 6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD + else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK + } + val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase()) + locationChannelManager.setTeleported(true) + locationChannelManager.select(ChannelID.Location(channel)) + } catch (e: Exception) { + Log.e(TAG, "Failed to teleport to geohash: $geohash", e) + } + } + fun setTeleported(teleported: Boolean) = locationChannelManager.setTeleported(teleported) + fun beginLiveRefresh() = locationChannelManager.beginLiveRefresh() + fun endLiveRefresh() = locationChannelManager.endLiveRefresh() + + // Location Notes + fun setLocationNotesGeohash(geohash: String) = locationNotesManager.setGeohash(geohash) + fun cancelLocationNotes() = locationNotesManager.cancel() + fun refreshLocationNotes() = locationNotesManager.refresh() + fun clearLocationNotesError() = locationNotesManager.clearError() + fun sendLocationNote(content: String, nickname: String?) = locationNotesManager.send(content, nickname) + + // Bookmarks + fun toggleGeohashBookmark(geohash: String) = geohashBookmarksStore.toggle(geohash) + fun isGeohashBookmarked(geohash: String): Boolean = geohashBookmarksStore.isBookmarked(geohash) + fun resolveGeohashNameIfNeeded(geohash: String) = geohashBookmarksStore.resolveNameIfNeeded(geohash) + + fun setTorMode(mode: TorMode) { + TorPreferenceManager.set(getApplication(), mode) + } + + fun setPowEnabled(enabled: Boolean) { + poWPreferenceManager.setPowEnabled(enabled) + } + + fun setPowDifficulty(difficulty: Int) { + poWPreferenceManager.setPowDifficulty(difficulty) + } + + // Theme + val themePreference = ThemePreferenceManager.themeFlow + + fun setTheme(preference: ThemePreference) { + ThemePreferenceManager.set(getApplication(), preference) + } + + // Expose favorites service functionality for UI components + fun getOfflineFavorites() = favoritesService.getOurFavorites() + fun findNostrPubkey(noiseKey: ByteArray) = favoritesService.findNostrPubkey(noiseKey) + fun getFavoriteStatus(noiseKey: ByteArray) = favoritesService.getFavoriteStatus(noiseKey) + fun getFavoriteStatus(peerID: String) = favoritesService.getFavoriteStatus(peerID) /** * Clear all cryptographic data including persistent identity @@ -803,7 +922,7 @@ class ChatViewModel( // Clear FavoritesPersistenceService persistent relationships try { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites() + favoritesService.clearAllFavorites() Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") } catch (_: Exception) { } @@ -849,11 +968,7 @@ class ChatViewModel( startPrivateChat(convKey) } } - - fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { - geohashViewModel.selectLocationChannel(channel) - } - + /** * Block a user in geohash channels by their nickname */ 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 b338c8645..48a2518c9 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -3,20 +3,24 @@ package com.bitchat.android.ui import android.content.Context import android.content.SharedPreferences import android.util.Log -import com.google.gson.Gson +import kotlinx.serialization.json.* +import com.bitchat.android.util.JsonUtil import kotlin.random.Random +import jakarta.inject.Inject +import jakarta.inject.Singleton /** * Handles data persistence operations for the chat system */ -class DataManager(private val context: Context) { +@Singleton +class DataManager @Inject constructor(private val context: Context) { companion object { private const val TAG = "DataManager" } private val prefs: SharedPreferences = context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE) - private val gson = Gson() + // Channel-related maps that need to persist state private val _channelCreators = mutableMapOf() @@ -85,7 +89,11 @@ class DataManager(private val context: Context) { // Load channel creators val creatorsJson = prefs.getString("channel_creators", "{}") try { - val creatorsMap = gson.fromJson(creatorsJson, Map::class.java) as? Map + val creatorsMap = try { + JsonUtil.json.parseToJsonElement(creatorsJson ?: "{}").jsonObject.mapValues { + it.value.jsonPrimitive.content + } + } catch (e: Exception) { null } creatorsMap?.let { _channelCreators.putAll(it) } } catch (e: Exception) { // Ignore parsing errors @@ -105,7 +113,7 @@ class DataManager(private val context: Context) { prefs.edit().apply { putStringSet("joined_channels", joinedChannels) putStringSet("password_protected_channels", passwordProtectedChannels) - putString("channel_creators", gson.toJson(_channelCreators)) + putString("channel_creators", JsonUtil.toJson(_channelCreators)) apply() } } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index b69926d02..98d3e7a56 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -42,6 +42,7 @@ import androidx.core.view.updateLayoutParams import com.bitchat.android.geohash.Geohash import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.ui.theme.BASE_FONT_SIZE +import org.koin.android.ext.android.inject @OptIn(ExperimentalMaterial3Api::class) class GeohashPickerActivity : OrientationAwareActivity() { @@ -68,7 +69,7 @@ class GeohashPickerActivity : OrientationAwareActivity() { } catch (_: Throwable) {} } else { // If no initial geohash, try to use the user's coarsest location - val locationManager = LocationChannelManager.getInstance(applicationContext) + val locationManager: LocationChannelManager by inject() val channels = locationManager.availableChannels.value if (!channels.isNullOrEmpty()) { val coarsestChannel = channels.minByOrNull { it.geohash.length } 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 060bb84e7..5138539b9 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -12,33 +12,43 @@ import com.bitchat.android.nostr.NostrIdentityBridge import com.bitchat.android.nostr.NostrProtocol import com.bitchat.android.nostr.NostrRelayManager import com.bitchat.android.nostr.NostrSubscriptionManager +import com.bitchat.android.nostr.NostrTransport import com.bitchat.android.nostr.PoWPreferenceManager +import jakarta.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel import java.util.Date -class GeohashViewModel( +@KoinViewModel +class GeohashViewModel @Inject constructor( application: Application, private val state: ChatState, private val messageManager: MessageManager, private val privateChatManager: PrivateChatManager, private val meshDelegateHandler: MeshDelegateHandler, private val dataManager: DataManager, - private val notificationManager: NotificationManager + private val notificationManager: NotificationManager, + private val nostrRelayManager: NostrRelayManager, + private val nostrTransport: NostrTransport, + private val seenStore: com.bitchat.android.services.SeenMessageStore, + private val locationChannelManager: com.bitchat.android.geohash.LocationChannelManager, + private val powPreferenceManager: PoWPreferenceManager ) : AndroidViewModel(application) { companion object { private const val TAG = "GeohashViewModel" } private val repo = GeohashRepository(application, state, dataManager) - private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope) + private val subscriptionManager = NostrSubscriptionManager(nostrRelayManager, viewModelScope) private val geohashMessageHandler = GeohashMessageHandler( application = application, state = state, messageManager = messageManager, repo = repo, scope = viewModelScope, - dataManager = dataManager + dataManager = dataManager, + powPreferenceManager = powPreferenceManager ) private val dmHandler = NostrDirectMessageHandler( application = application, @@ -47,13 +57,14 @@ class GeohashViewModel( meshDelegateHandler = meshDelegateHandler, scope = viewModelScope, repo = repo, - dataManager = dataManager + dataManager = dataManager, + nostrTransport = nostrTransport, + seenStore = seenStore, ) private var currentGeohashSubId: String? = null private var currentDmSubId: String? = null private var geoTimer: Job? = null - private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null val geohashPeople: LiveData> = state.geohashPeople val geohashParticipantCounts: LiveData> = state.geohashParticipantCounts @@ -72,12 +83,11 @@ class GeohashViewModel( ) } try { - locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) - locationChannelManager?.selectedChannel?.observeForever { channel -> + locationChannelManager.selectedChannel.observeForever { channel -> state.setSelectedLocationChannel(channel) switchLocationChannel(channel) } - locationChannelManager?.teleported?.observeForever { teleported -> + locationChannelManager.teleported.observeForever { teleported -> state.setIsTeleported(teleported) } } catch (e: Exception) { @@ -102,7 +112,7 @@ class GeohashViewModel( viewModelScope.launch { try { val tempId = "temp_${System.currentTimeMillis()}_${kotlin.random.Random.nextInt(1000)}" - val pow = PoWPreferenceManager.getCurrentSettings() + val pow = powPreferenceManager.getCurrentSettings() val localMsg = com.bitchat.android.model.BitchatMessage( id = tempId, sender = nickname ?: myPeerID, @@ -116,18 +126,17 @@ class GeohashViewModel( messageManager.addChannelMessage("geo:${channel.geohash}", localMsg) val startedMining = pow.enabled && pow.difficulty > 0 if (startedMining) { - com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempId) + PoWMiningTracker.startMiningMessage(tempId) } try { val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication()) val teleported = state.isTeleported.value ?: false - val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported) - val relayManager = NostrRelayManager.getInstance(getApplication()) - relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5) + val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported, powPreferenceManager) + nostrRelayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5) } finally { // Ensure we stop the per-message mining animation regardless of success/failure if (startedMining) { - com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage(tempId) + PoWMiningTracker.stopMiningMessage(tempId) } } } catch (e: Exception) { @@ -197,10 +206,6 @@ class GeohashViewModel( } } - fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { - locationChannelManager?.select(channel) ?: run { Log.w(TAG, "Cannot select location channel - not initialized") } - } - fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex) fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 251c8af90..52d8a19dc 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -53,19 +53,17 @@ fun LocationChannelsSheet( modifier: Modifier = Modifier ) { val context = LocalContext.current - val locationManager = LocationChannelManager.getInstance(context) - val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) } // Observe location manager state - val permissionState by locationManager.permissionState.observeAsState() - val availableChannels by locationManager.availableChannels.observeAsState(emptyList()) - val selectedChannel by locationManager.selectedChannel.observeAsState() - val locationNames by locationManager.locationNames.observeAsState(emptyMap()) - val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false) + val permissionState by viewModel.locationPermissionState.observeAsState() + val availableChannels by viewModel.availableLocationChannels.observeAsState(emptyList()) + val selectedChannel by viewModel.selectedLocationChannel.observeAsState() + val locationNames by viewModel.locationNames.observeAsState(emptyMap()) + val locationServicesEnabled by viewModel.locationServicesEnabled.observeAsState(false) // Observe bookmarks state - val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList()) - val bookmarkNames by bookmarksStore.bookmarkNames.observeAsState(emptyMap()) + val bookmarks by viewModel.geohashBookmarks.observeAsState(emptyList()) + val bookmarkNames by viewModel.geohashBookmarkNames.observeAsState(emptyMap()) // Observe reactive participant counts val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap()) @@ -164,7 +162,7 @@ fun LocationChannelsSheet( when (permissionState) { LocationChannelManager.PermissionState.NOT_DETERMINED -> { Button( - onClick = { locationManager.enableLocationChannels() }, + onClick = { viewModel.enableLocationChannels() }, colors = ButtonDefaults.buttonColors( containerColor = standardGreen.copy(alpha = 0.12f), contentColor = standardGreen @@ -240,7 +238,7 @@ fun LocationChannelsSheet( titleBold = meshCount(viewModel) > 0, trailingContent = null, onClick = { - locationManager.select(ChannelID.Mesh) + viewModel.selectLocationChannel(ChannelID.Mesh) onDismiss() } ) @@ -258,7 +256,7 @@ fun LocationChannelsSheet( val subtitlePrefix = "#${channel.geohash} • $coverage" val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 val highlight = participantCount > 0 - val isBookmarked = bookmarksStore.isBookmarked(channel.geohash) + val isBookmarked = viewModel.isGeohashBookmarked(channel.geohash) ChannelRow( title = geohashTitleWithCount(channel, participantCount), @@ -267,7 +265,7 @@ fun LocationChannelsSheet( titleColor = standardGreen, titleBold = highlight, trailingContent = { - IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) { + IconButton(onClick = { viewModel.toggleGeohashBookmark(channel.geohash) }) { Icon( imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, contentDescription = if (isBookmarked) stringResource(R.string.cd_remove_bookmark) else stringResource(R.string.cd_add_bookmark), @@ -277,8 +275,8 @@ fun LocationChannelsSheet( }, onClick = { // Selecting a suggested nearby channel is not a teleport - locationManager.setTeleported(false) - locationManager.select(ChannelID.Location(channel)) + viewModel.setTeleported(false) + viewModel.selectLocationChannel(ChannelID.Location(channel)) onDismiss() } ) @@ -330,7 +328,7 @@ fun LocationChannelsSheet( titleColor = null, titleBold = participantCount > 0, trailingContent = { - IconButton(onClick = { bookmarksStore.toggle(gh) }) { + IconButton(onClick = { viewModel.toggleGeohashBookmark(gh) }) { Icon( imageVector = Icons.Filled.Bookmark, contentDescription = stringResource(R.string.cd_remove_bookmark), @@ -342,15 +340,15 @@ fun LocationChannelsSheet( // For bookmarked selection, mark teleported based on regional membership val inRegional = availableChannels.any { it.geohash == gh } if (!inRegional && availableChannels.isNotEmpty()) { - locationManager.setTeleported(true) + viewModel.setTeleported(true) } else { - locationManager.setTeleported(false) + viewModel.setTeleported(false) } - locationManager.select(ChannelID.Location(channel)) + viewModel.selectLocationChannel(ChannelID.Location(channel)) onDismiss() } ) - LaunchedEffect(gh) { bookmarksStore.resolveNameIfNeeded(gh) } + LaunchedEffect(gh) { viewModel.resolveGeohashNameIfNeeded(gh) } } } @@ -454,8 +452,8 @@ fun LocationChannelsSheet( val level = levelForLength(normalized.length) val channel = GeohashChannel(level = level, geohash = normalized) // Mark this selection as a manual teleport - locationManager.setTeleported(true) - locationManager.select(ChannelID.Location(channel)) + viewModel.setTeleported(true) + viewModel.selectLocationChannel(ChannelID.Location(channel)) onDismiss() } else { customError = context.getString(R.string.invalid_geohash) @@ -514,9 +512,9 @@ fun LocationChannelsSheet( Button( onClick = { if (locationServicesEnabled) { - locationManager.disableLocationServices() + viewModel.disableLocationServices() } else { - locationManager.enableLocationServices() + viewModel.enableLocationServices() } }, colors = ButtonDefaults.buttonColors( @@ -572,13 +570,13 @@ fun LocationChannelsSheet( LaunchedEffect(isPresented, availableChannels, bookmarks) { if (isPresented) { if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { - locationManager.refreshChannels() - locationManager.beginLiveRefresh() + viewModel.refreshLocationChannels() + viewModel.beginLiveRefresh() } val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList() viewModel.beginGeohashSampling(geohashes) } else { - locationManager.endLiveRefresh() + viewModel.endLiveRefresh() viewModel.endGeohashSampling() } } @@ -586,14 +584,14 @@ fun LocationChannelsSheet( // React to permission changes LaunchedEffect(permissionState) { if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { - locationManager.refreshChannels() + viewModel.refreshLocationChannels() } } // React to location services enable/disable LaunchedEffect(locationServicesEnabled) { if (locationServicesEnabled && permissionState == LocationChannelManager.PermissionState.AUTHORIZED) { - locationManager.refreshChannels() + viewModel.refreshLocationChannels() } } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 09084a920..05f6456e3 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -9,16 +9,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bitchat.android.R import com.bitchat.android.geohash.ChannelID import com.bitchat.android.geohash.LocationChannelManager -import com.bitchat.android.nostr.LocationNotesManager /** * Location Notes button component for MainHeader @@ -32,21 +29,18 @@ fun LocationNotesButton( modifier: Modifier = Modifier ) { val colorScheme = MaterialTheme.colorScheme - val context = LocalContext.current - + // Get channel and permission state val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() - val locationManager = remember { LocationChannelManager.getInstance(context) } - val permissionState by locationManager.permissionState.observeAsState() - val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false) + val permissionState by viewModel.locationPermissionState.observeAsState() + val locationServicesEnabled by viewModel.locationServicesEnabled.observeAsState(false) // Check both permission AND location services enabled val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED val locationEnabled = locationPermissionGranted && locationServicesEnabled // Get notes count from LocationNotesManager - val notesManager = remember { LocationNotesManager.getInstance() } - val notes by notesManager.notes.observeAsState(emptyList()) + val notes by viewModel.locationNotes.observeAsState(emptyList()) val notesCount = notes.size // Only show in mesh mode when location is authorized (iOS pattern) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 23b9d952b..6b5812cb7 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -26,7 +26,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.geohash.GeohashChannelLevel -import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.nostr.LocationNotesManager import java.text.SimpleDateFormat import java.util.* @@ -43,6 +42,7 @@ fun LocationNotesSheet( locationName: String?, nickname: String?, onDismiss: () -> Unit, + viewModel: ChatViewModel, modifier: Modifier = Modifier ) { val context = LocalContext.current @@ -51,22 +51,18 @@ fun LocationNotesSheet( // iOS color scheme val backgroundColor = if (isDark) Color.Black else Color.White val accentGreen = if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0) - - // Managers - val notesManager = remember { LocationNotesManager.getInstance() } - val locationManager = remember { LocationChannelManager.getInstance(context) } - + // State - val notes by notesManager.notes.observeAsState(emptyList()) - val state by notesManager.state.observeAsState(LocationNotesManager.State.IDLE) - val errorMessage by notesManager.errorMessage.observeAsState() - val initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false) + val notes by viewModel.locationNotes.observeAsState(emptyList()) + val state by viewModel.locationNotesState.observeAsState(LocationNotesManager.State.IDLE) + val errorMessage by viewModel.locationNotesErrorMessage.observeAsState() + val initialLoadComplete by viewModel.locationNotesInitialLoadComplete.observeAsState(false) // SIMPLIFIED: Get count directly from notes list (no separate counter needed) val count = notes.size // Get location name (building or block) - matches iOS locationNames lookup - val locationNames by locationManager.locationNames.observeAsState(emptyMap()) + val locationNames by viewModel.locationNames.observeAsState(emptyMap()) val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() } ?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() } @@ -79,13 +75,13 @@ fun LocationNotesSheet( // Effect to set geohash when sheet opens LaunchedEffect(geohash) { - notesManager.setGeohash(geohash) + viewModel.setLocationNotesGeohash(geohash) } // Cleanup when sheet closes DisposableEffect(Unit) { onDispose { - notesManager.cancel() + viewModel.cancelLocationNotes() } } @@ -129,7 +125,7 @@ fun LocationNotesSheet( state == LocationNotesManager.State.NO_RELAYS -> { item { NoRelaysRow( - onRetry = { notesManager.refresh() } + onRetry = { viewModel.refreshLocationNotes() } ) } } @@ -157,7 +153,7 @@ fun LocationNotesSheet( item { ErrorRow( message = error, - onDismiss = { notesManager.clearError() } + onDismiss = { viewModel.clearLocationNotesError() } ) } } @@ -182,7 +178,7 @@ fun LocationNotesSheet( onSend = { val content = draft.trim() if (content.isNotEmpty()) { - notesManager.send(content, nickname) + viewModel.sendLocationNote(content, nickname) draft = "" } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt index 9d4da4c9e..797c96a61 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt @@ -5,13 +5,10 @@ import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.bitchat.android.geohash.GeohashChannelLevel -import com.bitchat.android.geohash.LocationChannelManager /** * Presenter component for LocationNotesSheet @@ -24,9 +21,7 @@ fun LocationNotesSheetPresenter( viewModel: ChatViewModel, onDismiss: () -> Unit ) { - val context = LocalContext.current - val locationManager = remember { LocationChannelManager.getInstance(context) } - val availableChannels by locationManager.availableChannels.observeAsState(emptyList()) + val availableChannels by viewModel.availableLocationChannels.observeAsState(emptyList()) val nickname by viewModel.nickname.observeAsState("") // iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash @@ -34,7 +29,7 @@ fun LocationNotesSheetPresenter( if (buildingGeohash != null) { // Get location name from locationManager - val locationNames by locationManager.locationNames.observeAsState(emptyMap()) + val locationNames by viewModel.locationNames.observeAsState(emptyMap()) val locationName = locationNames[GeohashChannelLevel.BUILDING] ?: locationNames[GeohashChannelLevel.BLOCK] @@ -42,13 +37,14 @@ fun LocationNotesSheetPresenter( geohash = buildingGeohash, locationName = locationName, nickname = nickname, - onDismiss = onDismiss + onDismiss = onDismiss, + viewModel = viewModel, ) } else { // No building geohash available - show error state (matches iOS) LocationNotesErrorSheet( onDismiss = onDismiss, - locationManager = locationManager + viewModel = viewModel, ) } } @@ -60,7 +56,7 @@ fun LocationNotesSheetPresenter( @Composable private fun LocationNotesErrorSheet( onDismiss: () -> Unit, - locationManager: LocationChannelManager + viewModel: ChatViewModel, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( @@ -88,10 +84,10 @@ private fun LocationNotesErrorSheet( Spacer(modifier = Modifier.height(24.dp)) Button(onClick = { // UNIFIED FIX: Enable location services first (user toggle) - locationManager.enableLocationServices() + viewModel.enableLocationServices() // Then request location channels (which will also request permission if needed) - locationManager.enableLocationChannels() - locationManager.refreshChannels() + viewModel.enableLocationChannels() + viewModel.refreshLocationChannels() }) { Text("Enable Location") } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index d6a9e7467..7adf212c7 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -1,5 +1,6 @@ package com.bitchat.android.ui +import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.ui.NotificationTextUtils import com.bitchat.android.mesh.BluetoothMeshService @@ -21,7 +22,9 @@ class MeshDelegateHandler( private val coroutineScope: CoroutineScope, private val onHapticFeedback: () -> Unit, private val getMyPeerID: () -> String, - private val getMeshService: () -> BluetoothMeshService + private val getMeshService: () -> BluetoothMeshService, + private val messageRouter: com.bitchat.android.services.MessageRouter, + private val favoritesService: FavoritesPersistenceService ) : BluetoothMeshDelegate { override fun didReceiveMessage(message: BitchatMessage) { @@ -89,7 +92,7 @@ class MeshDelegateHandler( state.setIsConnected(peers.isNotEmpty()) notificationManager.showActiveUserNotification(peers) // Flush router outbox for any peers that just connected (and their noiseHex aliases) - runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onPeersUpdated(peers) } + runCatching { messageRouter.onPeersUpdated(peers) } // Clean up channel members who disconnected channelManager.cleanupDisconnectedMembers(peers, getMyPeerID()) @@ -115,7 +118,7 @@ class MeshDelegateHandler( } else { // Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases val prefix = alias.removePrefix("nostr_") - val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() } + val favs = try { favoritesService.getOurFavorites() } catch (_: Exception) { emptyList() } favs.firstNotNullOfOrNull { rel -> rel.peerNostrPublicKey?.let { s -> runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec -> @@ -125,7 +128,7 @@ class MeshDelegateHandler( }?.takeIf { it.startsWith(prefix, ignoreCase = true) } } }, - findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } + findNoiseKeyForNostr = { key -> favoritesService.findNoiseKey(key) } ) if (canonical != currentPeer) { // Merge conversations and switch selection to the live mesh peer (or noiseHex) @@ -138,7 +141,7 @@ class MeshDelegateHandler( val info = getPeerInfo(currentPeer) val noiseKey = info?.noisePublicKey if (noiseKey != null) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) + favoritesService.getFavoriteStatus(noiseKey) } else null } catch (_: Exception) { null } @@ -167,7 +170,7 @@ class MeshDelegateHandler( val noiseHex = noiseKey.joinToString("") { b -> "%02x".format(b) } // Derive temp nostr key from favorites npub - val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKey) + val npub = favoritesService.findNostrPubkey(noiseKey) val tempNostrKey: String? = try { if (npub != null) { val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub) diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index e155b56fb..fd4dd625d 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -65,7 +65,8 @@ fun MessagesList( onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, onCancelTransfer: ((BitchatMessage) -> Unit)? = null, - onImageClick: ((String, List, Int) -> Unit)? = null + onImageClick: ((String, List, Int) -> Unit)? = null, + onGeohashClick: ((String) -> Unit)? = null ) { val listState = rememberLazyListState() @@ -129,7 +130,8 @@ fun MessagesList( onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, - onImageClick = onImageClick + onImageClick = onImageClick, + onGeohashClick = onGeohashClick ) } } @@ -145,7 +147,8 @@ fun MessageItem( onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, onCancelTransfer: ((BitchatMessage) -> Unit)? = null, - onImageClick: ((String, List, Int) -> Unit)? = null + onImageClick: ((String, List, Int) -> Unit)? = null, + onGeohashClick: ((String) -> Unit)? = null ) { val colorScheme = MaterialTheme.colorScheme val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } @@ -174,6 +177,7 @@ fun MessageItem( onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, onImageClick = onImageClick, + onGeohashClick = onGeohashClick, modifier = Modifier .weight(1f) .padding(end = endPad) @@ -211,6 +215,7 @@ fun MessageItem( onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, onImageClick: ((String, List, Int) -> Unit)?, + onGeohashClick: ((String) -> Unit)?, modifier: Modifier = Modifier ) { // Image special rendering @@ -414,21 +419,7 @@ fun MessageItem( ) if (geohashAnnotations.isNotEmpty()) { val geohash = geohashAnnotations.first().item - try { - val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance( - context - ) - val level = when (geohash.length) { - in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION - in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE - 5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY - 6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD - else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK - } - val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase()) - locationManager.setTeleported(true) - locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel)) - } catch (_: Exception) { } + onGeohashClick?.invoke(geohash) haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) return@detectTapGestures } diff --git a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt index b0d551289..24419b492 100644 --- a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt +++ b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.unit.sp import com.bitchat.android.nostr.NostrProofOfWork import androidx.compose.ui.res.stringResource import com.bitchat.android.R -import com.bitchat.android.nostr.PoWPreferenceManager /** * Shows the current Proof of Work status and settings @@ -25,11 +24,12 @@ import com.bitchat.android.nostr.PoWPreferenceManager @Composable fun PoWStatusIndicator( modifier: Modifier = Modifier, - style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT + style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT, + viewModel: ChatViewModel ) { - val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() - val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() - val isMining by PoWPreferenceManager.isMining.collectAsState() + val powEnabled by viewModel.powEnabled.collectAsState() + val powDifficulty by viewModel.powDifficulty.collectAsState() + val isMining by viewModel.isMining.collectAsState() val colorScheme = MaterialTheme.colorScheme val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index e8a3a4612..034d07fe2 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -8,6 +8,7 @@ import java.security.MessageDigest import com.bitchat.android.mesh.BluetoothMeshService import java.util.* import android.util.Log +import com.bitchat.android.favorites.FavoritesPersistenceService /** * Interface for Noise session operations needed by PrivateChatManager @@ -27,16 +28,15 @@ class PrivateChatManager( private val state: ChatState, private val messageManager: MessageManager, private val dataManager: DataManager, - private val noiseSessionDelegate: NoiseSessionDelegate + private val noiseSessionDelegate: NoiseSessionDelegate, + private val fingerprintManager: PeerFingerprintManager, + private val favoritesService: FavoritesPersistenceService ) { companion object { private const val TAG = "PrivateChatManager" } - // Use centralized fingerprint management - NO LOCAL STORAGE - private val fingerprintManager = PeerFingerprintManager.getInstance() - // Track received private messages that need read receipts private val unreadReceivedMessages = mutableMapOf>() @@ -415,7 +415,7 @@ class PrivateChatManager( // If we know the sender's Nostr pubkey for this peer via favorites, derive temp key try { val noiseKeyBytes = targetPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - val npub = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(noiseKeyBytes) + val npub = favoritesService.findNostrPubkey(noiseKeyBytes) if (npub != null) { // Normalize to hex to match how we formed temp keys (nostr_) val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npub) diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt index a8c59ef7e..3f4b34892 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -340,7 +340,7 @@ fun PeopleSection( } // Offline favorites (exclude ones mapped to connected) - val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() + val offlineFavorites = viewModel.getOfflineFavorites() offlineFavorites.forEach { fav -> val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) } val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } @@ -415,7 +415,7 @@ fun PeopleSection( // Resolve potential Nostr conversation key for this favorite (for unread detection) val nostrConvKey: String? = try { - val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) + val npubOrHex = viewModel.findNostrPubkey(fav.peerNoisePublicKey) if (npubOrHex != null) { val hex = if (npubOrHex.startsWith("npub")) { val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex) 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 e2f9d966e..53a01b21c 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 @@ -2,102 +2,101 @@ package com.bitchat.android.ui.debug import android.content.Context import android.content.SharedPreferences +import jakarta.inject.Inject +import jakarta.inject.Singleton /** * 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" - private const val KEY_PACKET_RELAY = "packet_relay_enabled" - private const val KEY_MAX_CONN_OVERALL = "max_connections_overall" - private const val KEY_MAX_CONN_SERVER = "max_connections_server" - private const val KEY_MAX_CONN_CLIENT = "max_connections_client" - private const val KEY_SEEN_PACKET_CAP = "seen_packet_capacity" - // 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" - - private lateinit var prefs: SharedPreferences - - fun init(context: Context) { - prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) +@Singleton +class DebugPreferenceManager @Inject constructor(context: Context) { + private companion object { + const val PREFS_NAME = "bitchat_debug_settings" + const val KEY_VERBOSE = "verbose_logging" + const val KEY_GATT_SERVER = "gatt_server_enabled" + const val KEY_GATT_CLIENT = "gatt_client_enabled" + const val KEY_PACKET_RELAY = "packet_relay_enabled" + const val KEY_MAX_CONN_OVERALL = "max_connections_overall" + const val KEY_MAX_CONN_SERVER = "max_connections_server" + const val KEY_MAX_CONN_CLIENT = "max_connections_client" + const val KEY_SEEN_PACKET_CAP = "seen_packet_capacity" + // GCS keys (no migration/back-compat) + const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes" + const val KEY_GCS_FPR = "gcs_filter_fpr_percent" } - private fun ready(): Boolean = ::prefs.isInitialized + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) fun getVerboseLogging(default: Boolean = false): Boolean = - if (ready()) prefs.getBoolean(KEY_VERBOSE, default) else default + prefs.getBoolean(KEY_VERBOSE, default) fun setVerboseLogging(value: Boolean) { - if (ready()) prefs.edit().putBoolean(KEY_VERBOSE, value).apply() + prefs.edit().putBoolean(KEY_VERBOSE, value).apply() } fun getGattServerEnabled(default: Boolean = true): Boolean = - if (ready()) prefs.getBoolean(KEY_GATT_SERVER, default) else default + prefs.getBoolean(KEY_GATT_SERVER, default) fun setGattServerEnabled(value: Boolean) { - if (ready()) prefs.edit().putBoolean(KEY_GATT_SERVER, value).apply() + prefs.edit().putBoolean(KEY_GATT_SERVER, value).apply() } fun getGattClientEnabled(default: Boolean = true): Boolean = - if (ready()) prefs.getBoolean(KEY_GATT_CLIENT, default) else default + prefs.getBoolean(KEY_GATT_CLIENT, default) fun setGattClientEnabled(value: Boolean) { - if (ready()) prefs.edit().putBoolean(KEY_GATT_CLIENT, value).apply() + prefs.edit().putBoolean(KEY_GATT_CLIENT, value).apply() } fun getPacketRelayEnabled(default: Boolean = true): Boolean = - if (ready()) prefs.getBoolean(KEY_PACKET_RELAY, default) else default + prefs.getBoolean(KEY_PACKET_RELAY, default) fun setPacketRelayEnabled(value: Boolean) { - if (ready()) prefs.edit().putBoolean(KEY_PACKET_RELAY, value).apply() + prefs.edit().putBoolean(KEY_PACKET_RELAY, value).apply() } // 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 + prefs.getInt(KEY_MAX_CONN_OVERALL, default) fun setMaxConnectionsOverall(value: Int) { - if (ready()) prefs.edit().putInt(KEY_MAX_CONN_OVERALL, value).apply() + prefs.edit().putInt(KEY_MAX_CONN_OVERALL, value).apply() } fun getMaxConnectionsServer(default: Int = 8): Int = - if (ready()) prefs.getInt(KEY_MAX_CONN_SERVER, default) else default + prefs.getInt(KEY_MAX_CONN_SERVER, default) fun setMaxConnectionsServer(value: Int) { - if (ready()) prefs.edit().putInt(KEY_MAX_CONN_SERVER, value).apply() + prefs.edit().putInt(KEY_MAX_CONN_SERVER, value).apply() } fun getMaxConnectionsClient(default: Int = 8): Int = - if (ready()) prefs.getInt(KEY_MAX_CONN_CLIENT, default) else default + prefs.getInt(KEY_MAX_CONN_CLIENT, default) fun setMaxConnectionsClient(value: Int) { - if (ready()) prefs.edit().putInt(KEY_MAX_CONN_CLIENT, value).apply() + prefs.edit().putInt(KEY_MAX_CONN_CLIENT, value).apply() } // Sync/GCS settings fun getSeenPacketCapacity(default: Int = 500): Int = - if (ready()) prefs.getInt(KEY_SEEN_PACKET_CAP, default) else default + prefs.getInt(KEY_SEEN_PACKET_CAP, default) fun setSeenPacketCapacity(value: Int) { - if (ready()) prefs.edit().putInt(KEY_SEEN_PACKET_CAP, value).apply() + prefs.edit().putInt(KEY_SEEN_PACKET_CAP, value).apply() } fun getGcsMaxFilterBytes(default: Int = 400): Int = - if (ready()) prefs.getInt(KEY_GCS_MAX_BYTES, default) else default + prefs.getInt(KEY_GCS_MAX_BYTES, default) fun setGcsMaxFilterBytes(value: Int) { - if (ready()) prefs.edit().putInt(KEY_GCS_MAX_BYTES, value).apply() + prefs.edit().putInt(KEY_GCS_MAX_BYTES, value).apply() } 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 + java.lang.Double.longBitsToDouble(prefs.getLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(default))) fun setGcsFprPercent(value: Double) { - if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply() + prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply() } } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt index 6e37286fb..044f39be1 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsManager.kt @@ -1,28 +1,20 @@ package com.bitchat.android.ui.debug +import jakarta.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.util.Date import java.util.concurrent.ConcurrentLinkedQueue +import jakarta.inject.Singleton /** * Debug settings manager for controlling debug features and collecting debug data */ -class DebugSettingsManager private constructor() { - // NOTE: This singleton is referenced from mesh layer. Keep in ui.debug but avoid Compose deps. - - companion object { - @Volatile - private var INSTANCE: DebugSettingsManager? = null - - fun getInstance(): DebugSettingsManager { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: DebugSettingsManager().also { INSTANCE = it } - } - } - } - +@Singleton +class DebugSettingsManager @Inject constructor( + private val debugPreferenceManager: DebugPreferenceManager +) { // Debug settings state private val _verboseLoggingEnabled = MutableStateFlow(false) val verboseLoggingEnabled: StateFlow = _verboseLoggingEnabled.asStateFlow() @@ -47,13 +39,13 @@ class DebugSettingsManager private constructor() { init { // Load persisted defaults (if preference manager already initialized) try { - _verboseLoggingEnabled.value = DebugPreferenceManager.getVerboseLogging(false) - _gattServerEnabled.value = DebugPreferenceManager.getGattServerEnabled(true) - _gattClientEnabled.value = DebugPreferenceManager.getGattClientEnabled(true) - _packetRelayEnabled.value = DebugPreferenceManager.getPacketRelayEnabled(true) - _maxConnectionsOverall.value = DebugPreferenceManager.getMaxConnectionsOverall(8) - _maxServerConnections.value = DebugPreferenceManager.getMaxConnectionsServer(8) - _maxClientConnections.value = DebugPreferenceManager.getMaxConnectionsClient(8) + _verboseLoggingEnabled.value = debugPreferenceManager.getVerboseLogging(false) + _gattServerEnabled.value = debugPreferenceManager.getGattServerEnabled(true) + _gattClientEnabled.value = debugPreferenceManager.getGattClientEnabled(true) + _packetRelayEnabled.value = debugPreferenceManager.getPacketRelayEnabled(true) + _maxConnectionsOverall.value = debugPreferenceManager.getMaxConnectionsOverall(8) + _maxServerConnections.value = debugPreferenceManager.getMaxConnectionsServer(8) + _maxClientConnections.value = debugPreferenceManager.getMaxConnectionsClient(8) } catch (_: Exception) { // Preferences not ready yet; keep defaults. They will be applied on first change. } @@ -107,7 +99,7 @@ class DebugSettingsManager private constructor() { // MARK: - Setting Controls fun setVerboseLoggingEnabled(enabled: Boolean) { - DebugPreferenceManager.setVerboseLogging(enabled) + debugPreferenceManager.setVerboseLogging(enabled) _verboseLoggingEnabled.value = enabled if (enabled) { addDebugMessage(DebugMessage.SystemMessage("🔊 Verbose logging enabled")) @@ -117,7 +109,7 @@ class DebugSettingsManager private constructor() { } fun setGattServerEnabled(enabled: Boolean) { - DebugPreferenceManager.setGattServerEnabled(enabled) + debugPreferenceManager.setGattServerEnabled(enabled) _gattServerEnabled.value = enabled addDebugMessage(DebugMessage.SystemMessage( if (enabled) "🟢 GATT Server enabled" else "🔴 GATT Server disabled" @@ -125,7 +117,7 @@ class DebugSettingsManager private constructor() { } fun setGattClientEnabled(enabled: Boolean) { - DebugPreferenceManager.setGattClientEnabled(enabled) + debugPreferenceManager.setGattClientEnabled(enabled) _gattClientEnabled.value = enabled addDebugMessage(DebugMessage.SystemMessage( if (enabled) "🟢 GATT Client enabled" else "🔴 GATT Client disabled" @@ -133,7 +125,7 @@ class DebugSettingsManager private constructor() { } fun setPacketRelayEnabled(enabled: Boolean) { - DebugPreferenceManager.setPacketRelayEnabled(enabled) + debugPreferenceManager.setPacketRelayEnabled(enabled) _packetRelayEnabled.value = enabled addDebugMessage(DebugMessage.SystemMessage( if (enabled) "📡 Packet relay enabled" else "🚫 Packet relay disabled" @@ -142,21 +134,21 @@ class DebugSettingsManager private constructor() { fun setMaxConnectionsOverall(value: Int) { val clamped = value.coerceIn(1, 32) - DebugPreferenceManager.setMaxConnectionsOverall(clamped) + debugPreferenceManager.setMaxConnectionsOverall(clamped) _maxConnectionsOverall.value = clamped addDebugMessage(DebugMessage.SystemMessage("🔢 Max overall connections set to $clamped")) } fun setMaxServerConnections(value: Int) { val clamped = value.coerceIn(1, 32) - DebugPreferenceManager.setMaxConnectionsServer(clamped) + debugPreferenceManager.setMaxConnectionsServer(clamped) _maxServerConnections.value = clamped addDebugMessage(DebugMessage.SystemMessage("🖥️ Max server connections set to $clamped")) } fun setMaxClientConnections(value: Int) { val clamped = value.coerceIn(1, 32) - DebugPreferenceManager.setMaxConnectionsClient(clamped) + debugPreferenceManager.setMaxConnectionsClient(clamped) _maxClientConnections.value = clamped addDebugMessage(DebugMessage.SystemMessage("📱 Max client connections set to $clamped")) } @@ -203,32 +195,32 @@ class DebugSettingsManager private constructor() { } // Sync/GCS settings (UI-configurable) - private val _seenPacketCapacity = MutableStateFlow(DebugPreferenceManager.getSeenPacketCapacity(500)) + private val _seenPacketCapacity = MutableStateFlow(debugPreferenceManager.getSeenPacketCapacity(500)) val seenPacketCapacity: StateFlow = _seenPacketCapacity.asStateFlow() - private val _gcsMaxBytes = MutableStateFlow(DebugPreferenceManager.getGcsMaxFilterBytes(400)) + private val _gcsMaxBytes = MutableStateFlow(debugPreferenceManager.getGcsMaxFilterBytes(400)) val gcsMaxBytes: StateFlow = _gcsMaxBytes.asStateFlow() - private val _gcsFprPercent = MutableStateFlow(DebugPreferenceManager.getGcsFprPercent(1.0)) + private val _gcsFprPercent = MutableStateFlow(debugPreferenceManager.getGcsFprPercent(1.0)) val gcsFprPercent: StateFlow = _gcsFprPercent.asStateFlow() fun setSeenPacketCapacity(value: Int) { val clamped = value.coerceIn(10, 1000) - DebugPreferenceManager.setSeenPacketCapacity(clamped) + debugPreferenceManager.setSeenPacketCapacity(clamped) _seenPacketCapacity.value = clamped addDebugMessage(DebugMessage.SystemMessage("🧩 max packets per sync set to $clamped")) } fun setGcsMaxBytes(value: Int) { val clamped = value.coerceIn(128, 1024) - DebugPreferenceManager.setGcsMaxFilterBytes(clamped) + debugPreferenceManager.setGcsMaxFilterBytes(clamped) _gcsMaxBytes.value = clamped addDebugMessage(DebugMessage.SystemMessage("🌸 max GCS filter size set to $clamped bytes")) } fun setGcsFprPercent(value: Double) { val clamped = value.coerceIn(0.1, 5.0) - DebugPreferenceManager.setGcsFprPercent(clamped) + debugPreferenceManager.setGcsFprPercent(clamped) _gcsFprPercent.value = clamped addDebugMessage(DebugMessage.SystemMessage("🎯 GCS FPR set to ${String.format("%.2f", clamped)}%")) } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 6cd255475..5c984c456 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -37,7 +37,7 @@ fun DebugSettingsSheet( ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) val colorScheme = MaterialTheme.colorScheme - val manager = remember { DebugSettingsManager.getInstance() } + val manager = org.koin.compose.koinInject() val verboseLogging by manager.verboseLoggingEnabled.collectAsState() val gattServerEnabled by manager.gattServerEnabled.collectAsState() diff --git a/app/src/main/java/com/bitchat/android/util/JsonUtil.kt b/app/src/main/java/com/bitchat/android/util/JsonUtil.kt new file mode 100644 index 000000000..168810ea0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/JsonUtil.kt @@ -0,0 +1,67 @@ +package com.bitchat.android.util + +import kotlinx.serialization.json.Json +import kotlinx.serialization.KSerializer + +/** + * JSON utility using kotlinx.serialization to replace Gson + */ +object JsonUtil { + + val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + prettyPrint = false + } + + /** + * Serialize object to JSON string + */ + inline fun toJson(value: T): String { + return json.encodeToString(value) + } + + /** + * Serialize object to JSON string with custom serializer + */ + fun toJson(serializer: KSerializer, value: T): String { + return json.encodeToString(serializer, value) + } + + /** + * Deserialize JSON string to object + */ + inline fun fromJson(jsonString: String): T { + return json.decodeFromString(jsonString) + } + + /** + * Deserialize JSON string to object with custom serializer + */ + fun fromJson(serializer: KSerializer, jsonString: String): T { + return json.decodeFromString(serializer, jsonString) + } + + /** + * Safe deserialization that returns null on error + */ + inline fun fromJsonOrNull(jsonString: String): T? { + return try { + json.decodeFromString(jsonString) + } catch (e: Exception) { + null + } + } + + /** + * Safe deserialization with custom serializer that returns null on error + */ + fun fromJsonOrNull(serializer: KSerializer, jsonString: String): T? { + return try { + json.decodeFromString(serializer, jsonString) + } catch (e: Exception) { + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/util/Serializers.kt b/app/src/main/java/com/bitchat/android/util/Serializers.kt new file mode 100644 index 000000000..de6a540c1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/util/Serializers.kt @@ -0,0 +1,72 @@ +package com.bitchat.android.util + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.util.* +import android.util.Base64 + +//TODO DELETE WHEN MIGRATE TO KOTLINX DATE TIME + +/** + * Serializer for Date objects using milliseconds since epoch + */ +object DateSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.LONG) + + override fun serialize(encoder: Encoder, value: Date) { + encoder.encodeLong(value.time) + } + + override fun deserialize(decoder: Decoder): Date { + return Date(decoder.decodeLong()) + } +} + +/** + * Serializer for ByteArray using Base64 encoding + */ +object ByteArraySerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ByteArray", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: ByteArray) { + encoder.encodeString(Base64.encodeToString(value, Base64.NO_WRAP)) + } + + override fun deserialize(decoder: Decoder): ByteArray { + return Base64.decode(decoder.decodeString(), Base64.NO_WRAP) + } +} + +/** + * Serializer for UByte values + */ +object UByteSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UByte", PrimitiveKind.INT) + + override fun serialize(encoder: Encoder, value: UByte) { + encoder.encodeInt(value.toInt()) + } + + override fun deserialize(decoder: Decoder): UByte { + return decoder.decodeInt().toUByte() + } +} + +/** + * Serializer for ULong values + */ +object ULongSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ULong", PrimitiveKind.LONG) + + override fun serialize(encoder: Encoder, value: ULong) { + encoder.encodeLong(value.toLong()) + } + + override fun deserialize(decoder: Decoder): ULong { + return decoder.decodeLong().toULong() + } +} \ No newline at end of file diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt index 6afdf6d60..da27a9f3b 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import android.content.Context import androidx.test.core.app.ApplicationProvider import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.mesh.PeerFingerprintManager import com.bitchat.android.model.BitchatMessage import junit.framework.TestCase.assertEquals @@ -41,7 +42,9 @@ class CommandProcessorTest() { state = chatState, messageManager = messageManager, dataManager = DataManager(context = context), - noiseSessionDelegate = mock() + noiseSessionDelegate = mock(), + fingerprintManager = mock(), + favoritesService = mock() ) ) } diff --git a/app/src/test/kotlin/com/bitchat/PeerManagerTest.kt b/app/src/test/kotlin/com/bitchat/PeerManagerTest.kt index d9c3897ed..a8f70cd89 100644 --- a/app/src/test/kotlin/com/bitchat/PeerManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/PeerManagerTest.kt @@ -7,7 +7,7 @@ import org.junit.Test class PeerManagerTest { - private val peerManager = PeerManager() + private val peerManager = PeerManager(fingerprintManager = com.bitchat.android.mesh.PeerFingerprintManager()) private val unknownPeer = "unknown" private val unknownDevice = "Unknown" diff --git a/build.gradle.kts b/build.gradle.kts index 440826843..e7f5ce3ae 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false } tasks.whenTaskAdded { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6dda224a..3f8a14e3e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ # Android and Kotlin agp = "8.10.1" kotlin = "2.2.0" +ksp = "2.3.0" compileSdk = "35" minSdk = "26" # API 26 for proper BLE support targetSdk = "34" @@ -25,12 +26,14 @@ accompanist-permissions = "0.37.3" bouncycastle = "1.70" tink-android = "1.10.0" -# JSON -gson = "2.13.1" + # Coroutines kotlinx-coroutines = "1.10.2" +# Serialization +kotlinx-serialization = "1.9.0" + # Bluetooth nordic-ble = "2.6.1" @@ -45,6 +48,9 @@ gms-location = "21.3.0" # Security security-crypto = "1.1.0-beta01" +koin = "4.1.1" +koin-annotation = "2.3.1" + # Testing junit = "4.13.2" androidx-test-ext = "1.2.1" @@ -85,12 +91,13 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bouncycastle" } google-tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink-android" } -# JSON -gson = { module = "com.google.code.gson:gson", version.ref = "gson" } # Coroutines kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +# Serialization +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + # Bluetooth nordic-ble = { module = "no.nordicsemi.android:ble", version.ref = "nordic-ble" } @@ -106,6 +113,16 @@ gms-location = { module = "com.google.android.gms:play-services-location", versi # Security androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" } +#koin +koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" } +koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } + +koin-annotation = { group = "io.insert-koin", name = "koin-annotations", version.ref = "koin-annotation" } +koin-annotation-compiler = { group = "io.insert-koin", name = "koin-ksp-compiler", version.ref = "koin-annotation" } +koin-jsr330 = { group = "io.insert-koin", name = "koin-jsr330", version.ref = "koin-annotation" } +koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } + + # Testing junit = { module = "junit:junit", version.ref = "junit" } androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" } @@ -116,13 +133,15 @@ mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = " mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito-inline" } roboelectric = { module = "org.robolectric:robolectric", version.ref = "roboelectric"} kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"} +koin-test = { group = "io.insert-koin", name = "koin-test", version.ref = "koin" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin-parcelize = { id = "kotlin-parcelize" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } [bundles] compose = [