Migrate from LiveData to Kotlin Flow (#518)

* Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025

* Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025

* refactor: new close button like ios(but not liquid glass)

* Automated update of relay data - Sun Oct  5 06:20:09 UTC 2025

* Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025

* Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025

* Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025

* Automated update of relay data - Sun Nov  2 06:22:16 UTC 2025

* Automated update of relay data - Sun Nov  9 06:21:43 UTC 2025

* Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025

* Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025

* Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025

* Chore: Remove unused `lifecycle-livedata-ktx` dependency

This commit removes the `androidx.lifecycle:lifecycle-livedata-ktx` library from the project's dependencies.

The `[libraries]` and `[bundles]` sections in `gradle/libs.versions.toml` have been updated to reflect this removal, as the dependency is no longer in use.

* Refactor: Remove unused `runtime-livedata` dependency

* Refactor: Migrate `LocationChannelManager` and `GeohashBookmarksStore` to StateFlow

This commit refactors `LocationChannelManager` and `GeohashBookmarksStore` to use `StateFlow` instead of `LiveData` for managing and exposing their state. This change aligns with modern Android development practices and improves testability.

**Key Changes:**

- **`LocationChannelManager`**:
    - All `MutableLiveData` properties (`permissionState`, `availableChannels`, `selectedChannel`, etc.) have been replaced with `MutableStateFlow`.
    - Consumers now access these properties as `StateFlow`.
    - State updates have been changed from `postValue()` to direct `.value` assignments, simplifying thread management within the manager which already uses a dedicated coroutine scope.

- **`GeohashBookmarksStore`**:
    - `bookmarks` and `bookmarkNames` are now exposed as `StateFlow` instead of `LiveData`.
    - State updates similarly use `.value` assignment.

- **Nullability**:
    - The non-nullable nature of `StateFlow`'s value reduces the need for null-checks in both the manager classes and their consumers, leading to safer code.

* Refactor: Migrate from LiveData to StateFlow for Nostr components

This commit replaces `LiveData` with `StateFlow` across core Nostr-related classes to align with modern Android architecture and improve state management. This change affects `NostrClient`, `NostrRelayManager`, `LocationNotesManager`, and `GeohashRepository`.

**Key Changes:**

-   **`NostrClient`**:
    -   `isInitialized` and `currentNpub` are now `StateFlow` instead of `LiveData`.
    -   `relayConnectionStatus` and `relayInfo` now return `StateFlow` from `NostrRelayManager`.

-   **`NostrRelayManager`**:
    -   Public properties `relays` and `isConnected` are migrated from `MutableLiveData` to `MutableStateFlow`.
    -   Updates are now pushed using `.value` instead of `.postValue()`.

-   **`LocationNotesManager`**:
    -   All public `LiveData` properties (`notes`, `geohash`, `initialLoadComplete`, `state`, `errorMessage`) are converted to `StateFlow`.
    -   The class documentation is updated to reflect the use of `StateFlow`.

-   **`GeohashRepository`**:
    -   Methods `updateGeohashPeople` and `updateReactiveParticipantCounts` now call `set...` methods on the `state` object instead of `post...`, reflecting the removal of `LiveData` from the underlying state management.

* Refactor: Migrate ChatState from LiveData to StateFlow

This commit refactors the `ChatState`, `ChatViewModel`, and `GeohashViewModel` to use `StateFlow` instead of `LiveData` for managing and exposing UI state. This migration improves state management by leveraging modern coroutine-based flows.

**Key Changes:**

- **`ChatState.kt`**:
    - Replaced all `MutableLiveData` instances with `MutableStateFlow`.
    - Exposed state properties as `StateFlow` instead of `LiveData`.
    - Removed `MediatorLiveData` for computed properties (`hasUnreadChannels`, `hasUnreadPrivateMessages`) and replaced them with `Flow.combine` to create derivative `StateFlows`.
    - Simplified non-nullable `getters` to directly return the `.value` of the `StateFlows`.
    - Removed `postValue` helpers that are no longer necessary.

- **`ChatViewModel.kt`**:
    - Updated all state properties to be `StateFlow`, reflecting the changes in `ChatState`.

- **`GeohashViewModel.kt`**:
    - Changed state properties (`geohashPeople`, `geohashParticipantCounts`, etc.) from `LiveData` to `StateFlow`.
    - Replaced `observeForever` on `LiveData` from `LocationChannelManager` with `viewModelScope.launch` blocks that `.collect()` from the underlying flows.

* Refactor: Migrate UI from LiveData to StateFlow

This commit replaces `LiveData.observeAsState()` with `StateFlow.collectAsState()` across various UI components. This change aligns the codebase with modern Android development practices, using Kotlin Flows for reactive UI state management.

No functional changes are intended. The primary goal is to remove the dependency on `androidx.lifecycle.livedata` from the composable functions.

**Affected Components:**
- `ChatScreen`
- `SidebarComponents`
- `ChatHeader`
- `LocationChannelsSheet`
- `LocationNotesSheet`
- `LocationNotesButton`
- `GeohashPeopleList`
- `LocationNotesSheetPresenter`

* Refactor: Use `collectAsStateWithLifecycle` for UI state collection

* Refactor: move CloseButton to core/ui/component

* Refactor: remove AI generated comments

* Refactor: fix combine to map and use WhileSubscribed

* Refactor: Pass CoroutineScope to ChatState

This commit refactors the `ChatState` class to accept a `CoroutineScope` in its constructor instead of creating its own.

**Key Changes:**

- **`ChatState.kt`**: The constructor now requires a `CoroutineScope`. This scope is used for the `stateIn` operators that convert `Flows` into `StateFlows` (`hasUnreadChannels`, `hasUnreadPrivateMessages`), ensuring they operate within the lifecycle of the provided scope.
- **`ChatViewModel.kt`**: The `viewModelScope` is now passed to the `ChatState` constructor during its instantiation. This ties the lifecycle of the state's coroutines directly to the `ViewModel`'s lifecycle.

* Test: Use `TestScope` for coroutines in `CommandProcessorTest`

This commit refactors `CommandProcessorTest` to use a `TestScope` and `UnconfinedTestDispatcher` for managing coroutines.

This ensures that coroutine-based operations within the test are executed in a controlled and predictable manner, improving test reliability. The `coroutineScope` for `CommandProcessor` and the `scope` for `ChatState` are now both configured to use this test-specific scope.

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
yet300
2025-12-13 15:58:48 +07:00
committed by GitHub
co-authored by GitHub Action
parent b2febcee88
commit e96330e50b
23 changed files with 426 additions and 397 deletions
@@ -0,0 +1,34 @@
package com.bitchat.android.core.ui.component.button
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun CloseButton(
onClick: () -> Unit,
modifier: Modifier = Modifier.Companion
) {
IconButton(
onClick = onClick,
modifier = modifier
.size(32.dp),
colors = IconButtonDefaults.iconButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f)
)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
modifier = Modifier.Companion.size(18.dp)
)
}
}
@@ -5,13 +5,14 @@ import android.location.Geocoder
import android.location.Location
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.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Locale
/**
@@ -46,11 +47,11 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
private val membership = mutableSetOf<String>()
private val _bookmarks = MutableLiveData<List<String>>(emptyList())
val bookmarks: LiveData<List<String>> = _bookmarks
private val _bookmarks = MutableStateFlow<List<String>>(emptyList())
val bookmarks: StateFlow<List<String>> = _bookmarks.asStateFlow()
private val _bookmarkNames = MutableLiveData<Map<String, String>>(emptyMap())
val bookmarkNames: LiveData<Map<String, String>> = _bookmarkNames
private val _bookmarkNames = MutableStateFlow<Map<String, String>>(emptyMap())
val bookmarkNames: StateFlow<Map<String, String>> = _bookmarkNames.asStateFlow()
// For throttling / preventing duplicate geocode lookups
private val resolving = mutableSetOf<String>()
@@ -68,8 +69,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
val gh = normalize(geohash)
if (gh.isEmpty() || membership.contains(gh)) return
membership.add(gh)
val updated = listOf(gh) + (_bookmarks.value ?: emptyList())
_bookmarks.postValue(updated)
val updated = listOf(gh) + (_bookmarks.value)
_bookmarks.value = updated
persist(updated)
// Resolve friendly name asynchronously
resolveNameIfNeeded(gh)
@@ -79,12 +80,12 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
val gh = normalize(geohash)
if (!membership.contains(gh)) return
membership.remove(gh)
val updated = (_bookmarks.value ?: emptyList()).filterNot { it == gh }
_bookmarks.postValue(updated)
val updated = (_bookmarks.value).filterNot { it == gh }
_bookmarks.value = updated
// Remove stored name to avoid stale cache growth
val names = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
val names = _bookmarkNames.value.toMutableMap()
if (names.remove(gh) != null) {
_bookmarkNames.postValue(names)
_bookmarkNames.value = names
persistNames(names)
}
persist(updated)
@@ -108,7 +109,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
}
}
membership.clear(); membership.addAll(seen)
_bookmarks.postValue(ordered)
_bookmarks.value = ordered
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load bookmarks: ${e.message}")
@@ -118,7 +119,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
if (!namesJson.isNullOrEmpty()) {
val mapType = object : TypeToken<Map<String, String>>() {}.type
val dict = gson.fromJson<Map<String, String>>(namesJson, mapType)
_bookmarkNames.postValue(dict)
_bookmarkNames.value = dict
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load bookmark names: ${e.message}")
@@ -127,14 +128,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
private fun persist() {
try {
val json = gson.toJson(_bookmarks.value ?: emptyList<String>())
val json = gson.toJson(_bookmarks.value)
prefs.edit().putString(STORE_KEY, json).apply()
} catch (_: Exception) {}
}
private fun persistNames() {
try {
val json = gson.toJson(_bookmarkNames.value ?: emptyMap<String, String>())
val json = gson.toJson(_bookmarkNames.value)
prefs.edit().putString(NAMES_STORE_KEY, json).apply()
} catch (_: Exception) {}
}
@@ -144,8 +145,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
fun clearAll() {
try {
membership.clear()
_bookmarks.postValue(emptyList())
_bookmarkNames.postValue(emptyMap())
_bookmarks.value = emptyList()
_bookmarkNames.value = emptyMap()
prefs.edit()
.remove(STORE_KEY)
.remove(NAMES_STORE_KEY)
@@ -209,9 +210,9 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
}
if (!name.isNullOrEmpty()) {
val current = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
val current = _bookmarkNames.value.toMutableMap()
current[gh] = name
_bookmarkNames.postValue(current)
_bookmarkNames.value = current
persistNames(current)
}
} catch (e: Exception) {
@@ -10,12 +10,12 @@ import android.location.LocationManager
import android.os.Bundle
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.*
import java.util.*
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Manages location permissions, one-shot location retrieval, and computing geohash channels.
@@ -53,28 +53,26 @@ class LocationChannelManager private constructor(private val context: Context) {
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)
val permissionState: LiveData<PermissionState> = _permissionState
private val _permissionState = MutableStateFlow(PermissionState.NOT_DETERMINED)
val permissionState: StateFlow<PermissionState> = _permissionState
private val _availableChannels = MutableLiveData<List<GeohashChannel>>(emptyList())
val availableChannels: LiveData<List<GeohashChannel>> = _availableChannels
private val _availableChannels = MutableStateFlow<List<GeohashChannel>>(emptyList())
val availableChannels: StateFlow<List<GeohashChannel>> = _availableChannels
private val _selectedChannel = MutableLiveData<ChannelID>(ChannelID.Mesh)
val selectedChannel: LiveData<ChannelID> = _selectedChannel
private val _selectedChannel = MutableStateFlow<ChannelID>(ChannelID.Mesh)
val selectedChannel: StateFlow<ChannelID> = _selectedChannel
private val _teleported = MutableLiveData(false)
val teleported: LiveData<Boolean> = _teleported
private val _teleported = MutableStateFlow(false)
val teleported: StateFlow<Boolean> = _teleported
private val _locationNames = MutableLiveData<Map<GeohashChannelLevel, String>>(emptyMap())
val locationNames: LiveData<Map<GeohashChannelLevel, String>> = _locationNames
private val _locationNames = MutableStateFlow<Map<GeohashChannelLevel, String>>(emptyMap())
val locationNames: StateFlow<Map<GeohashChannelLevel, String>> = _locationNames
// Add a new LiveData property to indicate when location is being fetched
private val _isLoadingLocation = MutableLiveData(false)
val isLoadingLocation: LiveData<Boolean> = _isLoadingLocation
private val _isLoadingLocation = MutableStateFlow(false)
val isLoadingLocation: StateFlow<Boolean> = _isLoadingLocation
// Add a new LiveData property to track if location services are enabled by user
private val _locationServicesEnabled = MutableLiveData(false)
val locationServicesEnabled: LiveData<Boolean> = _locationServicesEnabled
private val _locationServicesEnabled = MutableStateFlow(false)
val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled
init {
updatePermissionState()
@@ -102,15 +100,15 @@ class LocationChannelManager private constructor(private val context: Context) {
when (getCurrentPermissionStatus()) {
PermissionState.NOT_DETERMINED -> {
Log.d(TAG, "Permission not determined - user needs to grant in app settings")
_permissionState.postValue(PermissionState.NOT_DETERMINED)
_permissionState.value = PermissionState.NOT_DETERMINED
}
PermissionState.DENIED, PermissionState.RESTRICTED -> {
Log.d(TAG, "Permission denied or restricted")
_permissionState.postValue(PermissionState.DENIED)
_permissionState.value = PermissionState.DENIED
}
PermissionState.AUTHORIZED -> {
Log.d(TAG, "Permission authorized - requesting location")
_permissionState.postValue(PermissionState.AUTHORIZED)
_permissionState.value = PermissionState.AUTHORIZED
requestOneShotLocation()
}
}
@@ -180,7 +178,7 @@ class LocationChannelManager private constructor(private val context: Context) {
lastLocation?.let { location ->
when (channel) {
is ChannelID.Mesh -> {
_teleported.postValue(false)
_teleported.value = false
}
is ChannelID.Location -> {
val currentGeohash = Geohash.encode(
@@ -189,7 +187,7 @@ class LocationChannelManager private constructor(private val context: Context) {
precision = channel.channel.level.precision
)
val isTeleportedNow = currentGeohash != channel.channel.geohash
_teleported.postValue(isTeleportedNow)
_teleported.value = isTeleportedNow
Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})")
}
}
@@ -201,7 +199,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/
fun setTeleported(teleported: Boolean) {
Log.d(TAG, "Setting teleported status: $teleported")
_teleported.postValue(teleported)
_teleported.value = teleported
}
/**
@@ -209,7 +207,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/
fun enableLocationServices() {
Log.d(TAG, "enableLocationServices() called by user")
_locationServicesEnabled.postValue(true)
_locationServicesEnabled.value = true
saveLocationServicesState(true)
// If we have permission, start location operations
@@ -223,15 +221,15 @@ class LocationChannelManager private constructor(private val context: Context) {
*/
fun disableLocationServices() {
Log.d(TAG, "disableLocationServices() called by user")
_locationServicesEnabled.postValue(false)
_locationServicesEnabled.value = false
saveLocationServicesState(false)
// Stop any ongoing location operations
endLiveRefresh()
// Clear available channels when location is disabled
_availableChannels.postValue(emptyList())
_locationNames.postValue(emptyMap())
_availableChannels.value = emptyList()
_locationNames.value = emptyMap()
// If user had a location channel selected, switch back to mesh
if (_selectedChannel.value is ChannelID.Location) {
@@ -243,7 +241,7 @@ class LocationChannelManager private constructor(private val context: Context) {
* Check if location services are enabled by the user
*/
fun isLocationServicesEnabled(): Boolean {
return _locationServicesEnabled.value ?: false
return _locationServicesEnabled.value
}
// MARK: - Location Operations
@@ -280,13 +278,13 @@ class LocationChannelManager private constructor(private val context: Context) {
if (lastKnownLocation != null) {
Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}")
lastLocation = lastKnownLocation
_isLoadingLocation.postValue(false) // Make sure loading state is off
_isLoadingLocation.value = false // Make sure loading state is off
computeChannels(lastKnownLocation)
reverseGeocodeIfNeeded(lastKnownLocation)
} else {
Log.d(TAG, "No last known location available")
// Set loading state to true so UI can show a spinner
_isLoadingLocation.postValue(true)
_isLoadingLocation.value = true
// Request a fresh location only when we don't have a last known location
Log.d(TAG, "Requesting fresh location...")
@@ -294,7 +292,7 @@ class LocationChannelManager private constructor(private val context: Context) {
}
} catch (e: SecurityException) {
Log.e(TAG, "Security exception requesting location: ${e.message}")
_isLoadingLocation.postValue(false) // Turn off loading state on error
_isLoadingLocation.value = false // Turn off loading state on error
updatePermissionState()
}
}
@@ -308,7 +306,7 @@ class LocationChannelManager private constructor(private val context: Context) {
reverseGeocodeIfNeeded(location)
// Update loading state to indicate we have a location now
_isLoadingLocation.postValue(false)
_isLoadingLocation.value = false
// Remove this listener after getting the update
try {
@@ -322,13 +320,13 @@ class LocationChannelManager private constructor(private val context: Context) {
// Request a fresh location update using getCurrentLocation instead of continuous updates
private fun requestFreshLocation() {
if (!hasLocationPermission()) {
_isLoadingLocation.postValue(false) // Turn off loading state if no permission
_isLoadingLocation.value = false // Turn off loading state if no permission
return
}
try {
// Set loading state to true to indicate we're actively trying to get a location
_isLoadingLocation.postValue(true)
_isLoadingLocation.value = true
// Try common providers in order of preference
val providers = listOf(
@@ -358,7 +356,7 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.w(TAG, "Received null location from getCurrentLocation")
}
// Update loading state to indicate we have a location now
_isLoadingLocation.postValue(false)
_isLoadingLocation.value = false
}
)
} else {
@@ -378,14 +376,14 @@ class LocationChannelManager private constructor(private val context: Context) {
// If no provider was available, turn off loading state
if (!providerFound) {
Log.w(TAG, "No location providers available")
_isLoadingLocation.postValue(false)
_isLoadingLocation.value = false
}
} catch (e: SecurityException) {
Log.e(TAG, "Security exception requesting location: ${e.message}")
_isLoadingLocation.postValue(false) // Turn off loading state on error
_isLoadingLocation.value = false // Turn off loading state on error
} catch (e: Exception) {
Log.e(TAG, "Error requesting location: ${e.message}")
_isLoadingLocation.postValue(false) // Turn off loading state on error
_isLoadingLocation.value = false // Turn off loading state on error
}
}
@@ -408,7 +406,7 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun updatePermissionState() {
val newState = getCurrentPermissionStatus()
Log.d(TAG, "Permission state updated to: $newState")
_permissionState.postValue(newState)
_permissionState.value = newState
}
private fun hasLocationPermission(): Boolean {
@@ -433,13 +431,13 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.v(TAG, "Generated ${level.displayName}: $geohash")
}
_availableChannels.postValue(result)
_availableChannels.value = result
// Recompute teleported status based on current location vs selected channel
val selectedChannelValue = _selectedChannel.value
when (selectedChannelValue) {
is ChannelID.Mesh -> {
_teleported.postValue(false)
_teleported.value = false
}
is ChannelID.Location -> {
val currentGeohash = Geohash.encode(
@@ -448,12 +446,9 @@ class LocationChannelManager private constructor(private val context: Context) {
precision = selectedChannelValue.channel.level.precision
)
val isTeleported = currentGeohash != selectedChannelValue.channel.geohash
_teleported.postValue(isTeleported)
_teleported.value = isTeleported
Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})")
}
null -> {
_teleported.postValue(false)
}
}
}
@@ -482,7 +477,7 @@ class LocationChannelManager private constructor(private val context: Context) {
val names = namesByLevel(address)
Log.d(TAG, "Reverse geocoding result: $names")
_locationNames.postValue(names)
_locationNames.value = names
} else {
Log.w(TAG, "No reverse geocoding results")
}
@@ -601,26 +596,26 @@ class LocationChannelManager private constructor(private val context: Context) {
}
if (channel != null) {
_selectedChannel.postValue(channel)
_selectedChannel.value = channel
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
} else {
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
}
} else {
Log.w(TAG, "Invalid channel data format in persistence")
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
}
} else {
Log.d(TAG, "No persisted channel found, defaulting to Mesh")
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
}
} catch (e: JsonSyntaxException) {
Log.e(TAG, "Failed to parse persisted channel data: ${e.message}")
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
} catch (e: Exception) {
Log.e(TAG, "Failed to load persisted channel: ${e.message}")
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
}
}
@@ -629,7 +624,7 @@ class LocationChannelManager private constructor(private val context: Context) {
*/
fun clearPersistedChannel() {
dataManager?.clearLastGeohashChannel()
_selectedChannel.postValue(ChannelID.Mesh)
_selectedChannel.value = ChannelID.Mesh
Log.d(TAG, "Cleared persisted channel selection")
}
@@ -653,11 +648,11 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun loadLocationServicesState() {
try {
val enabled = dataManager?.isLocationServicesEnabled() ?: false
_locationServicesEnabled.postValue(enabled)
_locationServicesEnabled.value = enabled
Log.d(TAG, "Loaded location services state: $enabled")
} catch (e: Exception) {
Log.e(TAG, "Failed to load location services state: ${e.message}")
_locationServicesEnabled.postValue(false)
_locationServicesEnabled.value = false
}
}
@@ -2,7 +2,6 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import androidx.lifecycle.LiveData
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.GeoPerson
import java.util.Date
@@ -112,7 +111,7 @@ class GeohashRepository(
val geohash = currentGeohash
if (geohash == null) {
// Use postValue for thread safety - this can be called from background threads
state.postGeohashPeople(emptyList())
state.setGeohashPeople(emptyList())
return
}
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
@@ -143,7 +142,7 @@ class GeohashRepository(
)
}.sortedByDescending { it.lastSeen }
// Use postValue for thread safety - this can be called from background threads
state.postGeohashPeople(people)
state.setGeohashPeople(people)
}
fun updateReactiveParticipantCounts() {
@@ -155,7 +154,7 @@ class GeohashRepository(
counts[gh] = active
}
// Use postValue for thread safety - this can be called from background threads
state.postGeohashParticipantCounts(counts)
state.setGeohashParticipantCounts(counts)
}
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
@@ -2,13 +2,14 @@ package com.bitchat.android.nostr
import android.util.Log
import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Manages location notes (kind=1 text notes with geohash tags)
* iOS-compatible implementation with LiveData for Android UI binding
* iOS-compatible implementation with StateFlow for Android UI binding
*/
@MainThread
class LocationNotesManager private constructor() {
@@ -63,21 +64,21 @@ class LocationNotesManager private constructor() {
NO_RELAYS
}
// Published state (LiveData for Android)
private val _notes = MutableLiveData<List<Note>>(emptyList())
val notes: LiveData<List<Note>> = _notes
// Published state (StateFlow for Android)
private val _notes = MutableStateFlow<List<Note>>(emptyList())
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
private val _geohash = MutableLiveData<String?>(null)
val geohash: LiveData<String?> = _geohash
private val _geohash = MutableStateFlow<String?>(null)
val geohash: StateFlow<String?> = _geohash.asStateFlow()
private val _initialLoadComplete = MutableLiveData(false)
val initialLoadComplete: LiveData<Boolean> = _initialLoadComplete
private val _initialLoadComplete = MutableStateFlow(false)
val initialLoadComplete: StateFlow<Boolean> = _initialLoadComplete.asStateFlow()
private val _state = MutableLiveData(State.IDLE)
val state: LiveData<State> = _state
private val _state = MutableStateFlow(State.IDLE)
val state: StateFlow<State> = _state.asStateFlow()
private val _errorMessage = MutableLiveData<String?>(null)
val errorMessage: LiveData<String?> = _errorMessage
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
// Private state
private var subscriptionIDs: MutableMap<String, String> = mutableMapOf()
@@ -2,9 +2,10 @@ package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* High-level Nostr client that manages identity, connections, and messaging
@@ -30,11 +31,11 @@ class NostrClient private constructor(private val context: Context) {
private var currentIdentity: NostrIdentity? = null
// Client state
private val _isInitialized = MutableLiveData<Boolean>()
val isInitialized: LiveData<Boolean> = _isInitialized
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
private val _currentNpub = MutableLiveData<String>()
val currentNpub: LiveData<String> = _currentNpub
private val _currentNpub = MutableStateFlow<String?>(null)
val currentNpub: StateFlow<String?> = _currentNpub.asStateFlow()
// Message processing
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@@ -53,21 +54,21 @@ class NostrClient private constructor(private val context: Context) {
currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
if (currentIdentity != null) {
_currentNpub.postValue(currentIdentity!!.npub)
_currentNpub.value = currentIdentity!!.npub
Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}")
// Connect to relays
relayManager.connect()
_isInitialized.postValue(true)
_isInitialized.value = true
Log.i(TAG, "✅ Nostr client initialized successfully")
} else {
Log.e(TAG, "❌ Failed to load/create Nostr identity")
_isInitialized.postValue(false)
_isInitialized.value = false
}
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}")
_isInitialized.postValue(false)
_isInitialized.value = false
}
}
}
@@ -78,7 +79,7 @@ class NostrClient private constructor(private val context: Context) {
fun shutdown() {
Log.d(TAG, "Shutting down Nostr client")
relayManager.disconnect()
_isInitialized.postValue(false)
_isInitialized.value = false
}
/**
@@ -227,12 +228,12 @@ class NostrClient private constructor(private val context: Context) {
/**
* Get relay connection status
*/
val relayConnectionStatus: LiveData<Boolean> = relayManager.isConnected
val relayConnectionStatus: StateFlow<Boolean> = relayManager.isConnected
/**
* Get relay information
*/
val relayInfo: LiveData<List<NostrRelayManager.Relay>> = relayManager.relays
val relayInfo: StateFlow<List<NostrRelayManager.Relay>> = relayManager.relays
// MARK: - Private Methods
@@ -1,9 +1,10 @@
package com.bitchat.android.nostr
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.google.gson.JsonArray
import com.google.gson.JsonParser
import kotlinx.coroutines.*
@@ -72,11 +73,11 @@ class NostrRelayManager private constructor() {
)
// Published state
private val _relays = MutableLiveData<List<Relay>>()
val relays: LiveData<List<Relay>> = _relays
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
val relays: StateFlow<List<Relay>> = _relays.asStateFlow()
private val _isConnected = MutableLiveData<Boolean>()
val isConnected: LiveData<Boolean> = _isConnected
private val _isConnected = MutableStateFlow<Boolean>(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
// Internal state
private val relaysList = mutableListOf<Relay>()
@@ -226,14 +227,14 @@ class NostrRelayManager private constructor() {
"wss://nostr21.com"
)
relaysList.addAll(defaultRelayUrls.map { Relay(it) })
_relays.postValue(relaysList.toList())
_relays.value = relaysList.toList()
updateConnectionStatus()
Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays")
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e)
// Initialize with empty list as fallback
_relays.postValue(emptyList())
_isConnected.postValue(false)
_relays.value = emptyList()
_isConnected.value = false
}
}
@@ -797,12 +798,12 @@ class NostrRelayManager private constructor() {
}
private fun updateRelaysList() {
_relays.postValue(relaysList.toList())
_relays.value = relaysList.toList()
}
private fun updateConnectionStatus() {
val connected = relaysList.any { it.isConnected }
_isConnected.postValue(connected)
_isConnected.value = connected
}
private fun generateSubscriptionId(): String {
@@ -29,7 +29,9 @@ import androidx.compose.ui.unit.sp
import com.bitchat.android.nostr.NostrProofOfWork
import com.bitchat.android.nostr.PoWPreferenceManager
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.net.TorMode
import com.bitchat.android.net.TorPreferenceManager
import com.bitchat.android.net.ArtiTorManager
@@ -243,7 +245,7 @@ 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 com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsStateWithLifecycle()
Row(
modifier = Modifier.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
@@ -279,8 +281,8 @@ fun AboutSheet(
PoWPreferenceManager.init(context)
}
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle()
Column(
modifier = Modifier.padding(horizontal = 24.dp),
@@ -595,18 +597,12 @@ fun AboutSheet(
.height(64.dp)
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
) {
TextButton(
CloseButton(
onClick = onDismiss,
modifier = Modifier
modifier = modifier
.align(Alignment.CenterEnd)
.padding(horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.close_plain),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onBackground
)
}
.padding(horizontal = 16.dp),
)
}
}
}
@@ -14,7 +14,6 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -30,6 +29,7 @@ import androidx.compose.ui.unit.sp
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
import androidx.lifecycle.compose.collectAsStateWithLifecycle
/**
* Header components for ChatScreen
@@ -248,10 +248,10 @@ fun ChatHeaderContent(
when {
selectedPrivatePeer != null -> {
// Private chat header - Fully reactive state tracking
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap())
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap())
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
// Reactive favorite computation - no more static lookups!
val isFavorite = isFavoriteReactive(
@@ -264,8 +264,8 @@ fun ChatHeaderContent(
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState")
// Pass geohash context and people for NIP-17 chat title formatting
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
PrivateChatHeader(
peerID = selectedPrivatePeer,
@@ -523,18 +523,18 @@ private fun MainHeader(
viewModel: ChatViewModel
) {
val colorScheme = MaterialTheme.colorScheme
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val isConnected by viewModel.isConnected.observeAsState(false)
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
// 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 bookmarksStore.bookmarks.collectAsStateWithLifecycle()
Row(
modifier = Modifier.fillMaxWidth(),
@@ -653,8 +653,8 @@ private fun LocationChannelsButton(
val colorScheme = MaterialTheme.colorScheme
// Get current channel selection from location manager
val selectedChannel by viewModel.selectedLocationChannel.observeAsState()
val teleported by viewModel.isTeleported.observeAsState(false)
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
val (badgeText, badgeColor) = when (selectedChannel) {
is com.bitchat.android.geohash.ChannelID.Mesh -> {
@@ -10,7 +10,6 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Alignment
@@ -25,6 +24,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.media.FullScreenImageViewer
@@ -41,22 +41,22 @@ import com.bitchat.android.ui.media.FullScreenImageViewer
@Composable
fun ChatScreen(viewModel: ChatViewModel) {
val colorScheme = MaterialTheme.colorScheme
val messages by viewModel.messages.observeAsState(emptyList())
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
val nickname by viewModel.nickname.observeAsState("")
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
val currentChannel by viewModel.currentChannel.observeAsState()
val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet())
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
val channelMessages by viewModel.channelMessages.observeAsState(emptyMap())
val showSidebar by viewModel.showSidebar.observeAsState(false)
val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false)
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList())
val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false)
val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList())
val showAppInfo by viewModel.showAppInfo.observeAsState(false)
val messages by viewModel.messages.collectAsStateWithLifecycle()
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle()
val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle()
val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle()
val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
var messageText by remember { mutableStateOf(TextFieldValue("")) }
var showPasswordPrompt by remember { mutableStateOf(false) }
@@ -78,11 +78,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
showPasswordDialog = showPasswordPrompt
}
val isConnected by viewModel.isConnected.observeAsState(false)
val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null)
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle()
// Get location channel info for timeline switching
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
// Determine what messages to show based on current context (unified timelines)
val displayMessages = when {
@@ -1,10 +1,17 @@
package com.bitchat.android.ui
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import com.bitchat.android.model.BitchatMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* Centralized state definitions and data classes for the chat system
@@ -21,171 +28,162 @@ data class CommandSuggestion(
/**
* Contains all the observable state for the chat system
*/
class ChatState {
class ChatState(
scope: CoroutineScope
) {
// Core messages and peer state
private val _messages = MutableLiveData<List<BitchatMessage>>(emptyList())
val messages: LiveData<List<BitchatMessage>> = _messages
private val _messages = MutableStateFlow<List<BitchatMessage>>(emptyList())
val messages: StateFlow<List<BitchatMessage>> = _messages.asStateFlow()
private val _connectedPeers = MutableLiveData<List<String>>(emptyList())
val connectedPeers: LiveData<List<String>> = _connectedPeers
private val _connectedPeers = MutableStateFlow<List<String>>(emptyList())
val connectedPeers: StateFlow<List<String>> = _connectedPeers.asStateFlow()
private val _nickname = MutableLiveData<String>()
val nickname: LiveData<String> = _nickname
private val _nickname = MutableStateFlow<String>("")
val nickname: StateFlow<String> = _nickname.asStateFlow()
private val _isConnected = MutableLiveData<Boolean>(false)
val isConnected: LiveData<Boolean> = _isConnected
private val _isConnected = MutableStateFlow<Boolean>(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
// Private chats
private val _privateChats = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap())
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = _privateChats
private val _privateChats = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = _privateChats.asStateFlow()
private val _selectedPrivateChatPeer = MutableLiveData<String?>(null)
val selectedPrivateChatPeer: LiveData<String?> = _selectedPrivateChatPeer
private val _selectedPrivateChatPeer = MutableStateFlow<String?>(null)
val selectedPrivateChatPeer: StateFlow<String?> = _selectedPrivateChatPeer.asStateFlow()
private val _unreadPrivateMessages = MutableLiveData<Set<String>>(emptySet())
val unreadPrivateMessages: LiveData<Set<String>> = _unreadPrivateMessages
private val _unreadPrivateMessages = MutableStateFlow<Set<String>>(emptySet())
val unreadPrivateMessages: StateFlow<Set<String>> = _unreadPrivateMessages.asStateFlow()
// Channels
private val _joinedChannels = MutableLiveData<Set<String>>(emptySet())
val joinedChannels: LiveData<Set<String>> = _joinedChannels
private val _joinedChannels = MutableStateFlow<Set<String>>(emptySet())
val joinedChannels: StateFlow<Set<String>> = _joinedChannels.asStateFlow()
private val _currentChannel = MutableLiveData<String?>(null)
val currentChannel: LiveData<String?> = _currentChannel
private val _currentChannel = MutableStateFlow<String?>(null)
val currentChannel: StateFlow<String?> = _currentChannel.asStateFlow()
private val _channelMessages = MutableLiveData<Map<String, List<BitchatMessage>>>(emptyMap())
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = _channelMessages
private val _channelMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
private val _unreadChannelMessages = MutableLiveData<Map<String, Int>>(emptyMap())
val unreadChannelMessages: LiveData<Map<String, Int>> = _unreadChannelMessages
private val _unreadChannelMessages = MutableStateFlow<Map<String, Int>>(emptyMap())
val unreadChannelMessages: StateFlow<Map<String, Int>> = _unreadChannelMessages.asStateFlow()
private val _passwordProtectedChannels = MutableLiveData<Set<String>>(emptySet())
val passwordProtectedChannels: LiveData<Set<String>> = _passwordProtectedChannels
private val _passwordProtectedChannels = MutableStateFlow<Set<String>>(emptySet())
val passwordProtectedChannels: StateFlow<Set<String>> = _passwordProtectedChannels.asStateFlow()
private val _showPasswordPrompt = MutableLiveData<Boolean>(false)
val showPasswordPrompt: LiveData<Boolean> = _showPasswordPrompt
private val _showPasswordPrompt = MutableStateFlow<Boolean>(false)
val showPasswordPrompt: StateFlow<Boolean> = _showPasswordPrompt.asStateFlow()
private val _passwordPromptChannel = MutableLiveData<String?>(null)
val passwordPromptChannel: LiveData<String?> = _passwordPromptChannel
private val _passwordPromptChannel = MutableStateFlow<String?>(null)
val passwordPromptChannel: StateFlow<String?> = _passwordPromptChannel.asStateFlow()
// Sidebar state
private val _showSidebar = MutableLiveData(false)
val showSidebar: LiveData<Boolean> = _showSidebar
private val _showSidebar = MutableStateFlow(false)
val showSidebar: StateFlow<Boolean> = _showSidebar.asStateFlow()
// Command autocomplete
private val _showCommandSuggestions = MutableLiveData(false)
val showCommandSuggestions: LiveData<Boolean> = _showCommandSuggestions
private val _showCommandSuggestions = MutableStateFlow(false)
val showCommandSuggestions: StateFlow<Boolean> = _showCommandSuggestions.asStateFlow()
private val _commandSuggestions = MutableLiveData<List<CommandSuggestion>>(emptyList())
val commandSuggestions: LiveData<List<CommandSuggestion>> = _commandSuggestions
private val _commandSuggestions = MutableStateFlow<List<CommandSuggestion>>(emptyList())
val commandSuggestions: StateFlow<List<CommandSuggestion>> = _commandSuggestions.asStateFlow()
// Mention autocomplete
private val _showMentionSuggestions = MutableLiveData(false)
val showMentionSuggestions: LiveData<Boolean> = _showMentionSuggestions
private val _showMentionSuggestions = MutableStateFlow(false)
val showMentionSuggestions: StateFlow<Boolean> = _showMentionSuggestions.asStateFlow()
private val _mentionSuggestions = MutableLiveData<List<String>>(emptyList())
val mentionSuggestions: LiveData<List<String>> = _mentionSuggestions
private val _mentionSuggestions = MutableStateFlow<List<String>>(emptyList())
val mentionSuggestions: StateFlow<List<String>> = _mentionSuggestions.asStateFlow()
// Favorites
private val _favoritePeers = MutableLiveData<Set<String>>(emptySet())
val favoritePeers: LiveData<Set<String>> = _favoritePeers
private val _favoritePeers = MutableStateFlow<Set<String>>(emptySet())
val favoritePeers: StateFlow<Set<String>> = _favoritePeers.asStateFlow()
// Noise session states for peers (for reactive UI updates)
private val _peerSessionStates = MutableLiveData<Map<String, String>>(emptyMap())
val peerSessionStates: LiveData<Map<String, String>> = _peerSessionStates
private val _peerSessionStates = MutableStateFlow<Map<String, String>>(emptyMap())
val peerSessionStates: StateFlow<Map<String, String>> = _peerSessionStates.asStateFlow()
// Peer fingerprint state for reactive favorites (for reactive UI updates)
private val _peerFingerprints = MutableLiveData<Map<String, String>>(emptyMap())
val peerFingerprints: LiveData<Map<String, String>> = _peerFingerprints
private val _peerFingerprints = MutableStateFlow<Map<String, String>>(emptyMap())
val peerFingerprints: StateFlow<Map<String, String>> = _peerFingerprints.asStateFlow()
private val _peerNicknames = MutableLiveData<Map<String, String>>(emptyMap())
val peerNicknames: LiveData<Map<String, String>> = _peerNicknames
private val _peerNicknames = MutableStateFlow<Map<String, String>>(emptyMap())
val peerNicknames: StateFlow<Map<String, String>> = _peerNicknames.asStateFlow()
private val _peerRSSI = MutableLiveData<Map<String, Int>>(emptyMap())
val peerRSSI: LiveData<Map<String, Int>> = _peerRSSI
private val _peerRSSI = MutableStateFlow<Map<String, Int>>(emptyMap())
val peerRSSI: StateFlow<Map<String, Int>> = _peerRSSI.asStateFlow()
// Direct connection status per peer (for live UI updates)
private val _peerDirect = MutableLiveData<Map<String, Boolean>>(emptyMap())
val peerDirect: LiveData<Map<String, Boolean>> = _peerDirect
private val _peerDirect = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val peerDirect: StateFlow<Map<String, Boolean>> = _peerDirect.asStateFlow()
// peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager
// Navigation state
private val _showAppInfo = MutableLiveData<Boolean>(false)
val showAppInfo: LiveData<Boolean> = _showAppInfo
private val _showAppInfo = MutableStateFlow<Boolean>(false)
val showAppInfo: StateFlow<Boolean> = _showAppInfo.asStateFlow()
// Location channels state (for Nostr geohash features)
private val _selectedLocationChannel = MutableLiveData<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel
private val _selectedLocationChannel = MutableStateFlow<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel.asStateFlow()
private val _isTeleported = MutableLiveData<Boolean>(false)
val isTeleported: LiveData<Boolean> = _isTeleported
private val _isTeleported = MutableStateFlow<Boolean>(false)
val isTeleported: StateFlow<Boolean> = _isTeleported.asStateFlow()
// Geohash people state (iOS-compatible)
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople
// For background thread updates by repositories/handlers in their own scopes
val geohashPeopleMutable: MutableLiveData<List<GeoPerson>> get() = _geohashPeople
private val _geohashPeople = MutableStateFlow<List<GeoPerson>>(emptyList())
val geohashPeople: StateFlow<List<GeoPerson>> = _geohashPeople.asStateFlow()
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet())
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
private val _teleportedGeo = MutableStateFlow<Set<String>>(emptySet())
val teleportedGeo: StateFlow<Set<String>> = _teleportedGeo.asStateFlow()
// Geohash participant counts reactive state (for real-time location channel counts)
private val _geohashParticipantCounts = MutableLiveData<Map<String, Int>>(emptyMap())
val geohashParticipantCounts: LiveData<Map<String, Int>> = _geohashParticipantCounts
private val _geohashParticipantCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
val geohashParticipantCounts: StateFlow<Map<String, Int>> = _geohashParticipantCounts.asStateFlow()
// Unread state computed properties
val hasUnreadChannels: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
val hasUnreadPrivateMessages: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
init {
// Initialize unread state mediators
hasUnreadChannels.addSource(_unreadChannelMessages) { unreadMap ->
hasUnreadChannels.value = unreadMap.values.any { it > 0 }
}
val hasUnreadChannels: StateFlow<Boolean> = _unreadChannelMessages
.map { unreadMap -> unreadMap.values.any { it > 0 } }
.stateIn(
scope = scope,
started = WhileSubscribed(5_000),
initialValue = false
)
hasUnreadPrivateMessages.addSource(_unreadPrivateMessages) { unreadSet ->
hasUnreadPrivateMessages.value = unreadSet.isNotEmpty()
}
}
val hasUnreadPrivateMessages: StateFlow<Boolean> = _unreadPrivateMessages
.map { unreadSet -> unreadSet.isNotEmpty() }
.stateIn(
scope = scope,
started = WhileSubscribed(5_000),
initialValue = false
)
// Getters for internal state access
fun getMessagesValue() = _messages.value ?: emptyList()
fun getConnectedPeersValue() = _connectedPeers.value ?: emptyList()
fun getMessagesValue() = _messages.value
fun getConnectedPeersValue() = _connectedPeers.value
fun getNicknameValue() = _nickname.value
fun getPrivateChatsValue() = _privateChats.value ?: emptyMap()
fun getPrivateChatsValue() = _privateChats.value
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet()
fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet()
// Thread-safe posting helpers for background updates
fun postGeohashPeople(people: List<GeoPerson>) {
_geohashPeople.postValue(people)
}
fun postGeohashParticipantCounts(counts: Map<String, Int>) {
_geohashParticipantCounts.postValue(counts)
}
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value
fun getJoinedChannelsValue() = _joinedChannels.value
fun getCurrentChannelValue() = _currentChannel.value
fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap()
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap()
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value ?: emptySet()
fun getShowPasswordPromptValue() = _showPasswordPrompt.value ?: false
fun getChannelMessagesValue() = _channelMessages.value
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value
fun getShowPasswordPromptValue() = _showPasswordPrompt.value
fun getPasswordPromptChannelValue() = _passwordPromptChannel.value
fun getShowSidebarValue() = _showSidebar.value ?: false
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false
fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList()
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value ?: false
fun getMentionSuggestionsValue() = _mentionSuggestions.value ?: emptyList()
fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet()
fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap()
fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap()
fun getShowAppInfoValue() = _showAppInfo.value ?: false
fun getGeohashPeopleValue() = _geohashPeople.value ?: emptyList()
fun getTeleportedGeoValue() = _teleportedGeo.value ?: emptySet()
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value ?: emptyMap()
fun getShowSidebarValue() = _showSidebar.value
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value
fun getCommandSuggestionsValue() = _commandSuggestions.value
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value
fun getMentionSuggestionsValue() = _mentionSuggestions.value
fun getFavoritePeersValue() = _favoritePeers.value
fun getPeerSessionStatesValue() = _peerSessionStates.value
fun getPeerFingerprintsValue() = _peerFingerprints.value
fun getShowAppInfoValue() = _showAppInfo.value
fun getGeohashPeopleValue() = _geohashPeople.value
fun getTeleportedGeoValue() = _teleportedGeo.value
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value
// Setters for state updates
fun setMessages(messages: List<BitchatMessage>) {
@@ -197,7 +195,7 @@ class ChatState {
}
fun postTeleportedGeo(teleported: Set<String>) {
_teleportedGeo.postValue(teleported)
_teleportedGeo.value = teleported
}
fun setNickname(nickname: String) {
@@ -269,7 +267,7 @@ class ChatState {
}
fun setFavoritePeers(favorites: Set<String>) {
val currentValue = _favoritePeers.value ?: emptySet()
val currentValue = _favoritePeers.value
Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites")
Log.d("ChatState", "Current value: $currentValue")
Log.d("ChatState", "Values equal: ${currentValue == favorites}")
@@ -278,8 +276,7 @@ class ChatState {
// Always set the value - even if equal, this ensures observers are triggered
_favoritePeers.value = favorites
Log.d("ChatState", "LiveData value after set: ${_favoritePeers.value}")
Log.d("ChatState", "LiveData has active observers: ${_favoritePeers.hasActiveObservers()}")
Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}")
}
fun setPeerSessionStates(states: Map<String, String>) {
@@ -4,8 +4,8 @@ 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 kotlinx.coroutines.flow.StateFlow
import com.bitchat.android.mesh.BluetoothMeshDelegate
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
@@ -47,7 +47,9 @@ class ChatViewModel(
}
// MARK: - State management
private val state = ChatState()
private val state = ChatState(
scope = viewModelScope,
)
// Transfer progress tracking
private val transferMessageMap = mutableMapOf<String, String>()
@@ -103,40 +105,40 @@ class ChatViewModel(
// Expose state through LiveData (maintaining the same interface)
val messages: LiveData<List<BitchatMessage>> = state.messages
val connectedPeers: LiveData<List<String>> = state.connectedPeers
val nickname: LiveData<String> = state.nickname
val isConnected: LiveData<Boolean> = state.isConnected
val privateChats: LiveData<Map<String, List<BitchatMessage>>> = state.privateChats
val selectedPrivateChatPeer: LiveData<String?> = state.selectedPrivateChatPeer
val unreadPrivateMessages: LiveData<Set<String>> = state.unreadPrivateMessages
val joinedChannels: LiveData<Set<String>> = state.joinedChannels
val currentChannel: LiveData<String?> = state.currentChannel
val channelMessages: LiveData<Map<String, List<BitchatMessage>>> = state.channelMessages
val unreadChannelMessages: LiveData<Map<String, Int>> = state.unreadChannelMessages
val passwordProtectedChannels: LiveData<Set<String>> = state.passwordProtectedChannels
val showPasswordPrompt: LiveData<Boolean> = state.showPasswordPrompt
val passwordPromptChannel: LiveData<String?> = state.passwordPromptChannel
val showSidebar: LiveData<Boolean> = state.showSidebar
val messages: StateFlow<List<BitchatMessage>> = state.messages
val connectedPeers: StateFlow<List<String>> = state.connectedPeers
val nickname: StateFlow<String> = state.nickname
val isConnected: StateFlow<Boolean> = state.isConnected
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
val joinedChannels: StateFlow<Set<String>> = state.joinedChannels
val currentChannel: StateFlow<String?> = state.currentChannel
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = state.channelMessages
val unreadChannelMessages: StateFlow<Map<String, Int>> = state.unreadChannelMessages
val passwordProtectedChannels: StateFlow<Set<String>> = state.passwordProtectedChannels
val showPasswordPrompt: StateFlow<Boolean> = state.showPasswordPrompt
val passwordPromptChannel: StateFlow<String?> = state.passwordPromptChannel
val showSidebar: StateFlow<Boolean> = state.showSidebar
val hasUnreadChannels = state.hasUnreadChannels
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
val showCommandSuggestions: LiveData<Boolean> = state.showCommandSuggestions
val commandSuggestions: LiveData<List<CommandSuggestion>> = state.commandSuggestions
val showMentionSuggestions: LiveData<Boolean> = state.showMentionSuggestions
val mentionSuggestions: LiveData<List<String>> = state.mentionSuggestions
val favoritePeers: LiveData<Set<String>> = state.favoritePeers
val peerSessionStates: LiveData<Map<String, String>> = state.peerSessionStates
val peerFingerprints: LiveData<Map<String, String>> = state.peerFingerprints
val peerNicknames: LiveData<Map<String, String>> = state.peerNicknames
val peerRSSI: LiveData<Map<String, Int>> = state.peerRSSI
val peerDirect: LiveData<Map<String, Boolean>> = state.peerDirect
val showAppInfo: LiveData<Boolean> = state.showAppInfo
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
val isTeleported: LiveData<Boolean> = state.isTeleported
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
val teleportedGeo: LiveData<Set<String>> = state.teleportedGeo
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
val showCommandSuggestions: StateFlow<Boolean> = state.showCommandSuggestions
val commandSuggestions: StateFlow<List<CommandSuggestion>> = state.commandSuggestions
val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions
val mentionSuggestions: StateFlow<List<String>> = state.mentionSuggestions
val favoritePeers: StateFlow<Set<String>> = state.favoritePeers
val peerSessionStates: StateFlow<Map<String, String>> = state.peerSessionStates
val peerFingerprints: StateFlow<Map<String, String>> = state.peerFingerprints
val peerNicknames: StateFlow<Map<String, String>> = state.peerNicknames
val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
val isTeleported: StateFlow<Boolean> = state.isTeleported
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
init {
// Note: Mesh service delegate is now set by MainActivity
@@ -565,7 +567,7 @@ class ChatViewModel(
private fun logCurrentFavoriteState() {
Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}")
Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}")
Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}")
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
Log.i("ChatViewModel", "==============================")
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -21,6 +20,7 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import java.util.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
/**
@@ -46,11 +46,11 @@ fun GeohashPeopleList(
val colorScheme = MaterialTheme.colorScheme
// Observe geohash people from ChatViewModel
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val isTeleported by viewModel.isTeleported.observeAsState(false)
val nickname by viewModel.nickname.observeAsState("")
val unreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
Column {
// Header matching iOS style
@@ -3,7 +3,6 @@ package com.bitchat.android.ui
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import com.bitchat.android.nostr.GeohashMessageHandler
import com.bitchat.android.nostr.GeohashRepository
@@ -15,6 +14,7 @@ import com.bitchat.android.nostr.NostrSubscriptionManager
import com.bitchat.android.nostr.PoWPreferenceManager
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.util.Date
@@ -55,9 +55,9 @@ class GeohashViewModel(
private var geoTimer: Job? = null
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
fun initialize() {
subscriptionManager.connect()
@@ -73,12 +73,16 @@ class GeohashViewModel(
}
try {
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
locationChannelManager?.selectedChannel?.observeForever { channel ->
state.setSelectedLocationChannel(channel)
switchLocationChannel(channel)
viewModelScope.launch {
locationChannelManager?.selectedChannel?.collect { channel ->
state.setSelectedLocationChannel(channel)
switchLocationChannel(channel)
}
}
locationChannelManager?.teleported?.observeForever { teleported ->
state.setIsTeleported(teleported)
viewModelScope.launch {
locationChannelManager?.teleported?.collect { teleported ->
state.setIsTeleported(teleported)
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
@@ -120,7 +124,7 @@ class GeohashViewModel(
}
try {
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
val teleported = state.isTeleported.value ?: false
val teleported = state.isTeleported.value
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
val relayManager = NostrRelayManager.getInstance(getApplication())
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
@@ -231,7 +235,7 @@ class GeohashViewModel(
try {
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
val teleported = state.isTeleported.value ?: false
val teleported = state.isTeleported.value
if (teleported) repo.markTeleported(identity.publicKeyHex)
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
@@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.PinDrop
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
@@ -38,7 +37,9 @@ import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.geohash.GeohashBookmarksStore
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
/**
* Location Channels Sheet for selecting geohash-based location channels
@@ -57,18 +58,18 @@ fun LocationChannelsSheet(
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 locationManager.permissionState.collectAsStateWithLifecycle()
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
// Observe bookmarks state
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
val bookmarkNames by bookmarksStore.bookmarkNames.observeAsState(emptyMap())
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle()
// Observe reactive participant counts
val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap())
val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle()
// UI state
var customGeohash by remember { mutableStateOf("") }
@@ -551,18 +552,12 @@ fun LocationChannelsSheet(
.height(56.dp)
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
) {
TextButton(
CloseButton(
onClick = onDismiss,
modifier = Modifier
modifier = modifier
.align(Alignment.CenterEnd)
.padding(horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.close_plain),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onBackground
)
}
.padding(horizontal = 16.dp),
)
}
}
}
@@ -7,8 +7,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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
@@ -35,10 +35,10 @@ fun LocationNotesButton(
val context = LocalContext.current
// Get channel and permission state
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val locationManager = remember { LocationChannelManager.getInstance(context) }
val permissionState by locationManager.permissionState.observeAsState()
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle(false)
// Check both permission AND location services enabled
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
@@ -46,7 +46,7 @@ fun LocationNotesButton(
// Get notes count from LocationNotesManager
val notesManager = remember { LocationNotesManager.getInstance() }
val notes by notesManager.notes.observeAsState(emptyList())
val notes by notesManager.notes.collectAsStateWithLifecycle()
val notesCount = notes.size
// Only show in mesh mode when location is authorized (iOS pattern)
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -25,6 +24,7 @@ import com.bitchat.android.R
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager
@@ -57,16 +57,16 @@ fun LocationNotesSheet(
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 notesManager.notes.collectAsStateWithLifecycle()
val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE)
val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle()
val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(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 locationManager.locationNames.collectAsStateWithLifecycle()
val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() }
?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() }
@@ -4,12 +4,12 @@ import androidx.compose.foundation.layout.*
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
@@ -26,15 +26,15 @@ fun LocationNotesSheetPresenter(
) {
val context = LocalContext.current
val locationManager = remember { LocationChannelManager.getInstance(context) }
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
val nickname by viewModel.nickname.observeAsState("")
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
// iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash
if (buildingGeohash != null) {
// Get location name from locationManager
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val locationName = locationNames[GeohashChannelLevel.BUILDING]
?: locationNames[GeohashChannelLevel.BLOCK]
@@ -5,6 +5,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -26,7 +27,7 @@ private enum class CharacterAnimationState {
*/
@Composable
fun shouldAnimateMessage(messageId: String): Boolean {
val miningMessages by PoWMiningTracker.miningMessages.collectAsState()
val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle()
return miningMessages.contains(messageId)
}
@@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.nostr.NostrProofOfWork
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.nostr.PoWPreferenceManager
@@ -27,9 +28,9 @@ fun PoWStatusIndicator(
modifier: Modifier = Modifier,
style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT
) {
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
val isMining by PoWPreferenceManager.isMining.collectAsState()
val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle()
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle()
val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle()
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -22,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
@@ -39,14 +39,14 @@ fun SidebarOverlay(
val colorScheme = MaterialTheme.colorScheme
val interactionSource = remember { MutableInteractionSource() }
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList())
val currentChannel by viewModel.currentChannel.observeAsState()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
val nickname by viewModel.nickname.observeAsState("")
val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap())
val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap())
val peerRSSI by viewModel.peerRSSI.observeAsState(emptyMap())
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
Box(
modifier = modifier
@@ -110,7 +110,7 @@ fun SidebarOverlay(
// People section - switch between mesh and geohash lists (iOS-compatible)
item {
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsState()
when (selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> {
@@ -291,10 +291,10 @@ fun PeopleSection(
}
// Observe reactive state for favorites and fingerprints
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap())
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
// Reactive favorite computation for all peers
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
@@ -384,7 +384,7 @@ fun PeopleSection(
val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName)
val showHash = (baseNameCounts[bName] ?: 0) > 1
val directMap by viewModel.peerDirect.observeAsState(emptyMap())
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
PeerItem(
peerID = peerID,
@@ -5,7 +5,9 @@ import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
@@ -18,7 +20,10 @@ import java.util.Date
@RunWith(RobolectricTestRunner::class)
class CommandProcessorTest() {
private val context: Context = ApplicationProvider.getApplicationContext()
private val chatState = ChatState()
@OptIn(ExperimentalCoroutinesApi::class)
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val chatState = ChatState(scope = testScope)
private lateinit var commandProcessor: CommandProcessor
val messageManager: MessageManager = MessageManager(state = chatState)
@@ -26,7 +31,7 @@ class CommandProcessorTest() {
state = chatState,
messageManager = messageManager,
dataManager = DataManager(context = context),
coroutineScope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main.immediate)
coroutineScope = testScope
)
private val meshService: BluetoothMeshService = mock()
-4
View File
@@ -68,12 +68,10 @@ androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
# Lifecycle
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime" }
androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle-runtime" }
# Navigation
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
@@ -130,14 +128,12 @@ compose = [
"androidx-compose-ui-graphics",
"androidx-compose-ui-tooling-preview",
"androidx-compose-material3",
"androidx-compose-runtime-livedata",
"androidx-compose-material-icons-extended"
]
lifecycle = [
"androidx-lifecycle-runtime-ktx",
"androidx-lifecycle-viewmodel-compose",
"androidx-lifecycle-livedata-ktx"
]
cryptography = [