diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index a5a31ce6..2f8a7906 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -7,9 +7,10 @@ on: branches: [ "main", "develop" ] jobs: - test: + verify: + name: Test & Lint runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 @@ -39,7 +40,10 @@ jobs: - name: Run unit tests run: ./gradlew testDebugUnitTest - - name: Upload Test Reports (xml+html) + - name: Run lint + run: ./gradlew lintDebug + + - name: Upload test reports if: always() uses: actions/upload-artifact@v4 with: @@ -48,47 +52,6 @@ jobs: **/build/test-results/ **/build/reports/tests/ - - name: Upload test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results - path: | - **/build/test-results/ - **/build/reports/tests/ - - lint: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - - name: Setup Gradle - uses: gradle/gradle-build-action@v3 - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Cache Gradle packages - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Run lint - run: ./gradlew lintDebug - - name: Upload lint results uses: actions/upload-artifact@v4 if: always() @@ -97,9 +60,13 @@ jobs: path: '**/build/reports/lint-results-*.html' build: + name: Build ${{ matrix.variant }} runs-on: ubuntu-latest - needs: [test, lint] - + needs: verify + strategy: + matrix: + variant: [Debug, Release] + steps: - name: Checkout code uses: actions/checkout@v4 @@ -126,20 +93,11 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - - name: Build debug APK - run: ./gradlew assembleDebug + - name: Build ${{ matrix.variant }} APK + run: ./gradlew assemble${{ matrix.variant }} - - name: Build release APK - run: ./gradlew assembleRelease - - - name: Upload debug APK + - name: Upload ${{ matrix.variant }} APK uses: actions/upload-artifact@v4 with: - name: debug-apk - path: app/build/outputs/apk/debug/*.apk - - - name: Upload release APK - uses: actions/upload-artifact@v4 - with: - name: release-apk - path: app/build/outputs/apk/release/*.apk + name: ${{ matrix.variant }}-apk + path: app/build/outputs/apk/**/*.apk diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b3b8b7b..79495ee9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: with: distribution: temurin java-version: 17 - + - name: Setup Gradle uses: gradle/gradle-build-action@v2 with: @@ -38,30 +38,27 @@ jobs: - name: Grant execute permission for Gradlew run: chmod +x ./gradlew - - name: Build APK + - name: Build Release APK (with Tor) run: ./gradlew assembleRelease --no-daemon --stacktrace - + - name: List APK files run: | echo "APK files built:" - find app/build/outputs/apk/release -name "*.apk" -type f + find app/build/outputs/apk -name "*.apk" -type f - name: Rename APK run: | - mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat.apk - + mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat-android.apk + - name: DEBUG run: | set -x - pwd - ls -all - cd app/build/outputs/ ls -all - tree - + tree || ls -R + # Optional: Sign APK (requires secrets) # - name: Sign APK # uses: r0adkll/sign-android-release@v1 @@ -75,7 +72,7 @@ jobs: - name: Upload APK as artifact uses: actions/upload-artifact@v4 with: - name: bitchat-release-apk-${{ github.ref_name }} + name: bitchat-android-apk-${{ github.ref_name }} path: app/build/outputs/apk/release/*.apk retention-days: 30 if-no-files-found: error @@ -88,13 +85,22 @@ jobs: - name: Download APK artifact uses: actions/download-artifact@v4 with: - name: bitchat-release-apk-${{ github.ref_name }} - path: . + name: bitchat-android-apk-${{ github.ref_name }} + path: release - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - files: bitchat.apk + files: | + release/bitchat-android.apk name: Release ${{ github.ref_name }} + body: | + ## bitchat Android Release + + **bitchat-android.apk** (~15MB) + - Secure P2P messaging over Bluetooth mesh and Nostr + - Built-in Tor support (custom Arti build with 16KB page size) + - Compatible with Google Play requirements (Nov 2025+) + - Cross-platform compatible with iOS version env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 151e1b4f..64ac199e 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,9 @@ dependency-reduced-pom.xml # Other *.log .cxx/ -*build/ +# Gradle/Android build directories (but not tools/arti-build/) +**/build/ +!tools/arti-build/ out/ gen/ *~ @@ -57,3 +59,7 @@ google-services.json # Keystore files *.jks *.keystore + +# Arti build artifacts (cloned repo and Rust build cache) +tools/arti-build/.arti-source/ +tools/arti-build/target/ diff --git a/README.md b/README.md index 50f0cf54..c9b0091d 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ The app requires the following permissions (automatically requested): - **Bluetooth**: Core BLE functionality - **Location**: Required for BLE scanning on Android +- **Network**: Expand your mesh through public internet relays - **Notifications**: Message alerts and background updates ### Hardware Requirements diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca87d8dc..f6de2444 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.bitchat.droid" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 26 - versionName = "1.5.1" + versionCode = 27 + versionName = "1.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -30,6 +30,12 @@ android { } buildTypes { + debug { + ndk { + // Include x86_64 for emulator support during development + abiFilters += listOf("arm64-v8a", "x86_64") + } + } release { isMinifyEnabled = true isShrinkResources = true @@ -37,6 +43,11 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + ndk { + // ARM64-only to minimize APK size (~5.8MB savings) + // Excludes x86_64 as emulator not needed for production builds + abiFilters += listOf("arm64-v8a") + } } } compileOptions { @@ -95,8 +106,11 @@ dependencies { // WebSocket implementation(libs.okhttp) - // Arti (Tor in Rust) Android bridge - use published AAR with native libs - implementation("info.guardianproject:arti-mobile-ex:1.2.3") + // Arti (Tor in Rust) Android bridge - custom build from latest source + // Built with rustls, 16KB page size support, and onio//un service client + // Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar) + // Only included in tor flavor to reduce APK size for standard builds + // Note: AAR is kept in libs/ for reference, but libraries loaded from jniLibs/ // Google Play Services Location implementation(libs.gms.location) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f8b6946c..d3e004db 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -17,9 +17,12 @@ -keep class com.bitchat.android.nostr.** { *; } -keep class com.bitchat.android.identity.** { *; } -# Arti (Tor) ProGuard rules +# Keep Tor implementation (always included) +-keep class com.bitchat.android.net.RealTorProvider { *; } + +# Arti (Custom Tor implementation in Rust) ProGuard rules -keep class info.guardianproject.arti.** { *; } --keep class org.torproject.jni.** { *; } --keepnames class org.torproject.jni.** +-keep class org.torproject.arti.** { *; } +-keepnames class org.torproject.arti.** -dontwarn info.guardianproject.arti.** --dontwarn org.torproject.jni.** +-dontwarn org.torproject.arti.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8e953323..b228fd34 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,13 @@ + + + + + + + @@ -77,5 +84,34 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index df9cd6e5..b120b216 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -3,18 +3,21 @@ package com.bitchat.android import android.app.Application import com.bitchat.android.nostr.RelayDirectory import com.bitchat.android.ui.theme.ThemePreferenceManager -import com.bitchat.android.net.TorManager +import com.bitchat.android.net.ArtiTorManager /** * Main application class for bitchat Android */ class BitchatApplication : Application() { - + override fun onCreate() { super.onCreate() - + // Initialize Tor first so any early network goes over Tor - try { TorManager.init(this) } catch (_: Exception) { } + try { + val torProvider = ArtiTorManager.getInstance() + torProvider.init(this) + } catch (_: Exception){} // Initialize relay directory (loads assets/nostr_relays.csv) RelayDirectory.initialize(this) @@ -38,6 +41,12 @@ class BitchatApplication : Application() { // Initialize debug preference manager (persists debug toggles) try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { } + // Initialize mesh service preferences + try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { } + + // Proactively start the foreground service to keep mesh alive + try { com.bitchat.android.service.MeshForegroundService.start(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 28d67280..6e9fbd39 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -51,7 +51,7 @@ class MainActivity : OrientationAwareActivity() { private lateinit var locationStatusManager: LocationStatusManager private lateinit var batteryOptimizationManager: BatteryOptimizationManager - // Core mesh service - managed at app level + // Core mesh service - provided by the foreground service holder private lateinit var meshService: BluetoothMeshService private val mainViewModel: MainViewModel by viewModels() private val chatViewModel: ChatViewModel by viewModels { @@ -66,13 +66,21 @@ class MainActivity : OrientationAwareActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Check if this is a quit request from the notification + if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) { + android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity") + finish() + return + } + // Enable edge-to-edge display for modern Android look enableEdgeToEdge() // Initialize permission management permissionManager = PermissionManager(this) - // Initialize core mesh service first - meshService = BluetoothMeshService(this) + // Ensure foreground service is running and get mesh instance from holder + try { com.bitchat.android.service.MeshForegroundService.start(applicationContext) } catch (_: Exception) { } + meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext) bluetoothStatusManager = BluetoothStatusManager( activity = this, context = this, @@ -630,6 +638,15 @@ class MainActivity : OrientationAwareActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) + + // Check if this is a quit request from the notification + if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) { + android.util.Log.d("MainActivity", "Quit request received, finishing activity") + finish() + return + } + // Handle notification intents when app is already running if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { handleNotificationIntent(intent) @@ -640,6 +657,8 @@ class MainActivity : OrientationAwareActivity() { super.onResume() // Check Bluetooth and Location status on resume and handle accordingly if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { + // Reattach mesh delegate to new ChatViewModel instance after Activity recreation + try { meshService.delegate = chatViewModel } catch (_: Exception) { } // Set app foreground state meshService.connectionManager.setAppBackgroundState(false) chatViewModel.setAppBackgroundState(false) @@ -665,13 +684,15 @@ class MainActivity : OrientationAwareActivity() { } } - override fun onPause() { + override fun onPause() { super.onPause() // Only set background state if app is fully initialized if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { // Set app background state meshService.connectionManager.setAppBackgroundState(true) chatViewModel.setAppBackgroundState(true) + // Detach UI delegate so the foreground service can own DM notifications while UI is closed + try { meshService.delegate = null } catch (_: Exception) { } } } @@ -746,14 +767,6 @@ class MainActivity : OrientationAwareActivity() { Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}") } - // Stop mesh services if app was fully initialized - if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { - try { - meshService.stopServices() - Log.d("MainActivity", "Mesh services stopped successfully") - } catch (e: Exception) { - Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}") - } - } + // Do not stop mesh here; ForegroundService owns lifecycle for background reliability } } diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt new file mode 100644 index 00000000..96f408bc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt @@ -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) + ) + } +} \ No newline at end of file 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 b498dd83..d4ccb9bf 100644 --- a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt +++ b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt @@ -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() - private val _bookmarks = MutableLiveData>(emptyList()) - val bookmarks: LiveData> = _bookmarks + private val _bookmarks = MutableStateFlow>(emptyList()) + val bookmarks: StateFlow> = _bookmarks.asStateFlow() - private val _bookmarkNames = MutableLiveData>(emptyMap()) - val bookmarkNames: LiveData> = _bookmarkNames + private val _bookmarkNames = MutableStateFlow>(emptyMap()) + val bookmarkNames: StateFlow> = _bookmarkNames.asStateFlow() // For throttling / preventing duplicate geocode lookups private val resolving = mutableSetOf() @@ -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>() {}.type val dict = gson.fromJson>(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()) + 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()) + 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) { 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 b8da1fee..9c18a859 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -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 + private val _permissionState = MutableStateFlow(PermissionState.NOT_DETERMINED) + val permissionState: StateFlow = _permissionState - private val _availableChannels = MutableLiveData>(emptyList()) - val availableChannels: LiveData> = _availableChannels + private val _availableChannels = MutableStateFlow>(emptyList()) + val availableChannels: StateFlow> = _availableChannels - private val _selectedChannel = MutableLiveData(ChannelID.Mesh) - val selectedChannel: LiveData = _selectedChannel + private val _selectedChannel = MutableStateFlow(ChannelID.Mesh) + val selectedChannel: StateFlow = _selectedChannel - private val _teleported = MutableLiveData(false) - val teleported: LiveData = _teleported + private val _teleported = MutableStateFlow(false) + val teleported: StateFlow = _teleported - private val _locationNames = MutableLiveData>(emptyMap()) - val locationNames: LiveData> = _locationNames + private val _locationNames = MutableStateFlow>(emptyMap()) + val locationNames: StateFlow> = _locationNames - // Add a new LiveData property to indicate when location is being fetched - private val _isLoadingLocation = MutableLiveData(false) - val isLoadingLocation: LiveData = _isLoadingLocation + private val _isLoadingLocation = MutableStateFlow(false) + val isLoadingLocation: StateFlow = _isLoadingLocation - // Add a new LiveData property to track if location services are enabled by user - private val _locationServicesEnabled = MutableLiveData(false) - val locationServicesEnabled: LiveData = _locationServicesEnabled + private val _locationServicesEnabled = MutableStateFlow(false) + val locationServicesEnabled: StateFlow = _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 } } 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 3ac87764..8e0fa15b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -149,6 +149,7 @@ class BluetoothConnectionManager( try { isActive = true + Log.d(TAG, "ConnectionManager activated (permissions and adapter OK)") // set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable // try { @@ -179,6 +180,7 @@ class BluetoothConnectionManager( this@BluetoothConnectionManager.isActive = false return@launch } + Log.d(TAG, "GATT Server started") } else { Log.i(TAG, "GATT Server disabled by debug settings; not starting") } @@ -189,6 +191,7 @@ class BluetoothConnectionManager( this@BluetoothConnectionManager.isActive = false return@launch } + Log.d(TAG, "GATT Client started") } else { Log.i(TAG, "GATT Client disabled by debug settings; not starting") } @@ -214,6 +217,7 @@ class BluetoothConnectionManager( isActive = false connectionScope.launch { + Log.d(TAG, "Stopping client/server and power components...") // Stop component managers clientManager.stop() serverManager.stop() @@ -230,6 +234,18 @@ class BluetoothConnectionManager( Log.i(TAG, "All Bluetooth services stopped") } } + + /** + * Indicates whether this instance can be safely reused for a future start. + * Returns false if its coroutine scope has been cancelled. + */ + fun isReusable(): Boolean { + val active = connectionScope.isActive + if (!active) { + Log.d(TAG, "BluetoothConnectionManager isReusable=false (scope cancelled)") + } + return active + } /** * Set app background state for power optimization 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 dd12138d..3de48407 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -52,6 +52,12 @@ class BluetoothMeshService(private val context: Context) { internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access private val packetProcessor = PacketProcessor(myPeerID) private lateinit var gossipSyncManager: GossipSyncManager + // Service-level notification manager for background (no-UI) DMs + private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager( + context.applicationContext, + androidx.core.app.NotificationManagerCompat.from(context.applicationContext), + com.bitchat.android.util.NotificationIntervalManager() + ) // Service state management private var isActive = false @@ -61,8 +67,11 @@ class BluetoothMeshService(private val context: Context) { // Coroutines private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + // Tracks whether this instance has been terminated via stopServices() + private var terminated = false init { + Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID") setupDelegates() messageHandler.packetProcessor = packetProcessor //startPeriodicDebugLogging() @@ -98,6 +107,7 @@ class BluetoothMeshService(private val context: Context) { return signPacketBeforeBroadcast(packet) } } + Log.d(TAG, "Delegates set up; GossipSyncManager initialized") } /** @@ -105,6 +115,7 @@ class BluetoothMeshService(private val context: Context) { */ private fun startPeriodicDebugLogging() { serviceScope.launch { + Log.d(TAG, "Starting periodic debug logging loop") while (isActive) { try { delay(10000) // 10 seconds @@ -116,6 +127,7 @@ class BluetoothMeshService(private val context: Context) { Log.e(TAG, "Error in periodic debug logging: ${e.message}") } } + Log.d(TAG, "Periodic debug logging loop ended (isActive=$isActive)") } } @@ -124,6 +136,7 @@ class BluetoothMeshService(private val context: Context) { */ private fun sendPeriodicBroadcastAnnounce() { serviceScope.launch { + Log.d(TAG, "Starting periodic announce loop") while (isActive) { try { delay(30000) // 30 seconds @@ -132,6 +145,7 @@ class BluetoothMeshService(private val context: Context) { Log.e(TAG, "Error in periodic broadcast announce: ${e.message}") } } + Log.d(TAG, "Periodic announce loop ended (isActive=$isActive)") } } @@ -139,6 +153,7 @@ class BluetoothMeshService(private val context: Context) { * Setup delegate connections between components */ private fun setupDelegates() { + Log.d(TAG, "Setting up component delegates") // Provide nickname resolver to BLE broadcaster for detailed logs try { connectionManager.setNicknameResolver { pid -> peerManager.getPeerNickname(pid) } @@ -146,6 +161,9 @@ class BluetoothMeshService(private val context: Context) { // PeerManager delegates to main mesh service delegate peerManager.delegate = object : PeerManagerDelegate { override fun onPeerListUpdated(peerIDs: List) { + // Update process-wide state first + try { com.bitchat.android.services.AppStateStore.setPeers(peerIDs) } catch (_: Exception) { } + // Then notify UI delegate if attached delegate?.didUpdatePeerList(peerIDs) } override fun onPeerRemoved(peerID: String) { @@ -165,6 +183,7 @@ class BluetoothMeshService(private val context: Context) { override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) { // Send announcement and cached messages after key exchange serviceScope.launch { + Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups") delay(100) sendAnnouncementToPeer(peerID) @@ -352,7 +371,36 @@ class BluetoothMeshService(private val context: Context) { // Callbacks override fun onMessageReceived(message: BitchatMessage) { + // Always reflect into process-wide store so UI can hydrate after recreation + try { + when { + message.isPrivate -> { + val peer = message.senderPeerID ?: "" + if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message) + } + message.channel != null -> { + com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message) + } + else -> { + com.bitchat.android.services.AppStateStore.addPublicMessage(message) + } + } + } catch (_: Exception) { } + // And forward to UI delegate if attached delegate?.didReceiveMessage(message) + + // If no UI delegate attached (app closed), show DM notification via service manager + if (delegate == null && message.isPrivate) { + try { + val senderPeerID = message.senderPeerID + if (senderPeerID != null) { + val nick = try { peerManager.getPeerNickname(senderPeerID) } catch (_: Exception) { null } ?: senderPeerID + val preview = com.bitchat.android.ui.NotificationTextUtils.buildPrivateMessagePreview(message) + serviceNotificationManager.setAppBackgroundState(true) + serviceNotificationManager.showPrivateMessageNotification(senderPeerID, nick, preview) + } + } catch (_: Exception) { } + } } override fun onChannelLeave(channel: String, fromPeer: String) { @@ -477,12 +525,23 @@ class BluetoothMeshService(private val context: Context) { // BluetoothConnectionManager delegates connectionManager.delegate = object : BluetoothConnectionManagerDelegate { override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) { + // Log incoming for debug graphs (do not double-count anywhere else) + try { + val nick = getPeerNicknames()[peerID] + com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming( + packetType = packet.type.toString(), + fromPeerID = peerID, + fromNickname = nick, + fromDeviceAddress = device?.address + ) + } catch (_: Exception) { } packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address)) } override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) { // Send initial announcements after services are ready serviceScope.launch { + Log.d(TAG, "Device connected: ${device.address}; scheduling announce") delay(200) sendBroadcastAnnounce() } @@ -497,6 +556,7 @@ class BluetoothMeshService(private val context: Context) { } override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) { + Log.d(TAG, "Device disconnected: ${device.address}") val addr = device.address // Remove mapping and, if that was the last direct path for the peer, clear direct flag val peer = connectionManager.addressPeerMap[addr] @@ -535,6 +595,11 @@ class BluetoothMeshService(private val context: Context) { Log.w(TAG, "Mesh service already active, ignoring duplicate start request") return } + if (terminated) { + // This instance's scope was cancelled previously; refuse to start to avoid using dead scopes. + Log.e(TAG, "Mesh service instance was terminated; create a new instance instead of restarting") + return + } Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID") @@ -546,6 +611,7 @@ class BluetoothMeshService(private val context: Context) { Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)") // Start periodic syncs gossipSyncManager.start() + Log.d(TAG, "GossipSyncManager started") } else { Log.e(TAG, "Failed to start Bluetooth services") } @@ -567,11 +633,14 @@ class BluetoothMeshService(private val context: Context) { sendLeaveAnnouncement() serviceScope.launch { + Log.d(TAG, "Stopping subcomponents and cancelling scope...") delay(200) // Give leave message time to send // Stop all components gossipSyncManager.stop() + Log.d(TAG, "GossipSyncManager stopped") connectionManager.stopServices() + Log.d(TAG, "BluetoothConnectionManager stop requested") peerManager.shutdown() fragmentManager.shutdown() securityManager.shutdown() @@ -579,9 +648,24 @@ class BluetoothMeshService(private val context: Context) { messageHandler.shutdown() packetProcessor.shutdown() + // Mark this instance as terminated and cancel its scope so it won't be reused + terminated = true serviceScope.cancel() + Log.i(TAG, "BluetoothMeshService terminated and scope cancelled") } } + + /** + * Whether this instance can be safely reused. Returns false after stopServices() or if + * any critical internal scope has been cancelled. + */ + fun isReusable(): Boolean { + val reusable = !terminated && serviceScope.isActive && connectionManager.isReusable() + if (!reusable) { + Log.d(TAG, "isReusable=false (terminated=$terminated, scopeActive=${serviceScope.isActive}, connReusable=${connectionManager.isReusable()})") + } + return reusable + } /** * Send public message @@ -801,7 +885,7 @@ class BluetoothMeshService(private val context: Context) { fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { serviceScope.launch { 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() val isGeoAlias = try { @@ -812,8 +896,15 @@ class BluetoothMeshService(private val context: Context) { geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID) return@launch } - + try { + // Avoid duplicate read receipts: check persistent store first + val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null } + if (seenStore?.hasRead(messageID) == true) { + Log.d(TAG, "Skipping read receipt for $messageID - already marked read") + return@launch + } + // Create read receipt payload using NoisePayloadType exactly like iOS val readReceiptPayload = com.bitchat.android.model.NoisePayload( type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT, @@ -839,7 +930,10 @@ class BluetoothMeshService(private val context: Context) { val signedPacket = signPacketBeforeBroadcast(packet) connectionManager.broadcastPacket(RoutedPacket(signedPacket)) Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID") - + + // Persist as read after successful send + try { seenStore?.markRead(messageID) } catch (_: Exception) { } + } catch (e: Exception) { Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}") } @@ -852,7 +946,7 @@ class BluetoothMeshService(private val context: Context) { fun sendBroadcastAnnounce() { Log.d(TAG, "Sending broadcast announce") serviceScope.launch { - val nickname = delegate?.getNickname() ?: myPeerID + val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID } // Get the static public key for the announcement val staticKey = encryptionService.getStaticPublicKey() @@ -901,7 +995,7 @@ class BluetoothMeshService(private val context: Context) { fun sendAnnouncementToPeer(peerID: String) { if (peerManager.hasAnnouncedToPeer(peerID)) return - val nickname = delegate?.getNickname() ?: myPeerID + val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID } // Get the static public key for the announcement val staticKey = encryptionService.getStaticPublicKey() @@ -1000,6 +1094,13 @@ class BluetoothMeshService(private val context: Context) { return peerManager.getFingerprintForPeer(peerID) } + /** + * Get current active peer count (for status/notifications) + */ + fun getActivePeerCount(): Int { + return try { peerManager.getActivePeerCount() } catch (_: Exception) { 0 } + } + /** * Get peer info for verification purposes */ 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 b3474217..04e5d756 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -73,9 +73,17 @@ class BluetoothPacketBroadcaster( try { val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) } val toNick = toPeer?.let { nicknameResolver?.invoke(it) } - val isRelay = (incomingAddr != null || incomingPeer != null) - - com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logPacketRelayDetailed( + val manager = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance() + // Always log outgoing for the actual transmission target + manager.logOutgoing( + packetType = typeName, + toPeerID = toPeer, + toNickname = toNick, + toDeviceAddress = toDeviceAddress, + previousHopPeerID = incomingPeer + ) + // Keep the verbose relay message for human readability + manager.logPacketRelayDetailed( packetType = typeName, senderPeerID = senderPeerID, senderNickname = senderNick, @@ -86,7 +94,7 @@ class BluetoothPacketBroadcaster( toNickname = toNick, toDeviceAddress = toDeviceAddress, ttl = ttl, - isRelay = isRelay + isRelay = true ) } catch (_: Exception) { // Silently ignore debug logging failures diff --git a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt index 9db444e6..46bc394f 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt @@ -224,26 +224,29 @@ class PowerManager(private val context: Context) { } private fun updatePowerMode() { - val newMode = when { - // Always use performance mode when charging (unless in background too long) + // Determine the base mode from battery/charging state only + val baseMode = when { + // Charging in foreground may use performance isCharging && !isAppInBackground -> PowerMode.PERFORMANCE - - // Critical battery - use ultra low power + + // Critical battery - force ultra low power regardless of foreground/background batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER - - // Low battery - use power saver + + // Low battery - prefer power saver batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER - - // Background app with medium battery - use power saver - isAppInBackground && batteryLevel <= MEDIUM_BATTERY -> PowerMode.POWER_SAVER - - // Background app with good battery - use balanced - isAppInBackground -> PowerMode.BALANCED - - // Foreground with good battery - use balanced + + // Otherwise balanced else -> PowerMode.BALANCED } - + + // If app is in background (including when running as a foreground service), + // cap the power mode to at least POWER_SAVER. Preserve ULTRA_LOW_POWER. + val newMode = if (isAppInBackground) { + if (baseMode == PowerMode.ULTRA_LOW_POWER) PowerMode.ULTRA_LOW_POWER else PowerMode.POWER_SAVER + } else { + baseMode + } + if (newMode != currentMode) { val oldMode = currentMode currentMode = newMode diff --git a/app/src/main/java/com/bitchat/android/net/TorManager.kt b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt similarity index 52% rename from app/src/main/java/com/bitchat/android/net/TorManager.kt rename to app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt index b7c2230d..8dc0cad8 100644 --- a/app/src/main/java/com/bitchat/android/net/TorManager.kt +++ b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt @@ -2,6 +2,7 @@ package com.bitchat.android.net import android.app.Application import android.util.Log +import com.bitchat.android.util.AppConstants import info.guardianproject.arti.ArtiLogListener import info.guardianproject.arti.ArtiProxy import kotlinx.coroutines.CoroutineScope @@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.launch @@ -24,54 +26,97 @@ import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicLong /** - * Manages embedded Tor lifecycle & provides SOCKS proxy address. - * Uses Arti (Tor in Rust) for improved security and reliability. + * Tor provider implementation using custom-built Arti (Tor-in-Rust). + * + * This singleton provides Tor anonymity features using a custom Arti build + * compiled with 16KB page size support for Google Play compliance. + * + * Based on the original TorManager implementation. */ -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 +class ArtiTorManager private constructor() { + enum class TorState { + OFF, + STARTING, + BOOTSTRAPPING, + RUNNING, + STOPPING, + ERROR + } + + data class TorStatus( + val mode: TorMode = TorMode.OFF, + val running: Boolean = false, + val bootstrapPercent: Int = 0, + val lastLogLine: String = "", + val state: TorState = TorState.OFF + ) + + companion object { + private const val TAG = "ArtiTorManager" + private const val DEFAULT_SOCKS_PORT = AppConstants.Tor.DEFAULT_SOCKS_PORT + private const val RESTART_DELAY_MS = AppConstants.Tor.RESTART_DELAY_MS + private const val INACTIVITY_TIMEOUT_MS = AppConstants.Tor.INACTIVITY_TIMEOUT_MS + private const val MAX_RETRY_ATTEMPTS = AppConstants.Tor.MAX_RETRY_ATTEMPTS + private const val STOP_TIMEOUT_MS = AppConstants.Tor.STOP_TIMEOUT_MS + + @Volatile + private var INSTANCE: ArtiTorManager? = null + + fun getInstance(): ArtiTorManager { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: ArtiTorManager().also { INSTANCE = it } + } + } + } private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - @Volatile private var initialized = false - @Volatile private var socksAddr: InetSocketAddress? = null - private val artiProxyRef = AtomicReference(null) - @Volatile private var lastMode: TorMode = TorMode.OFF + @Volatile + private var initialized = false + @Volatile + private var socksAddr: InetSocketAddress? = null + @Volatile + private var artiProxy: ArtiProxy? = null + @Volatile + private var lastMode: TorMode = TorMode.OFF private val applyMutex = Mutex() - @Volatile private var desiredMode: TorMode = TorMode.OFF - @Volatile private var currentSocksPort: Int = DEFAULT_SOCKS_PORT - @Volatile private var lastLogTime = AtomicLong(0L) - @Volatile private var retryAttempts = 0 - @Volatile private var bindRetryAttempts = 0 + @Volatile + private var desiredMode: TorMode = TorMode.OFF + @Volatile + private var currentSocksPort: Int = DEFAULT_SOCKS_PORT + @Volatile + private var lastLogTime = AtomicLong(0L) + @Volatile + private var retryAttempts = 0 + @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 - enum class TorState { OFF, STARTING, BOOTSTRAPPING, RUNNING, STOPPING, ERROR } + @Volatile + private var lifecycleState: LifecycleState = LifecycleState.STOPPED - data class TorStatus( - val mode: TorMode = TorMode.OFF, - val running: Boolean = false, - val bootstrapPercent: Int = 0, // kept for backwards compatibility with UI; 0 or 100 only - val lastLogLine: String = "", - val state: TorState = TorState.OFF + private val _statusFlow = MutableStateFlow( + TorStatus( + mode = TorMode.OFF, + running = false, + bootstrapPercent = 0, + lastLogLine = "", + state = TorState.OFF + ) ) - private val _status = MutableStateFlow(TorStatus()) - val statusFlow: StateFlow = _status.asStateFlow() + val statusFlow: StateFlow = _statusFlow.asStateFlow() private val stateChangeDeferred = AtomicReference?>(null) fun isProxyEnabled(): Boolean { - val s = _status.value - return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null && s.state == TorState.RUNNING + val s = _statusFlow.value + return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && + socksAddr != null && s.state == TorState.RUNNING } fun init(application: Application) { @@ -82,7 +127,21 @@ object TorManager { currentApplication = application TorPreferenceManager.init(application) - // Apply saved mode at startup. If ON, set planned SOCKS immediately to avoid any leak. + val logListener = ArtiLogListener { logLine -> + val text = logLine ?: return@ArtiLogListener + val s = text + Log.i(TAG, "arti: $s") + lastLogTime.set(System.currentTimeMillis()) + _statusFlow.update { it.copy(lastLogLine = s) } + handleArtiLogLine(s) + } + + artiProxy = ArtiProxy.Builder(application) + .setSocksPort(currentSocksPort) + .setDnsPort(currentSocksPort + 1) + .setLogListener(logListener) + .build() + val savedMode = TorPreferenceManager.get(application) if (savedMode == TorMode.ON) { if (currentSocksPort < DEFAULT_SOCKS_PORT) { @@ -90,13 +149,15 @@ object TorManager { } desiredMode = savedMode socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) - try { OkHttpProvider.reset() } catch (_: Throwable) { } + try { + OkHttpProvider.reset() + } catch (_: Throwable) { + } // Only reset OkHttp during init } appScope.launch { applyMode(application, savedMode) } - // Observe changes appScope.launch { TorPreferenceManager.modeFlow.collect { mode -> applyMode(application, mode) @@ -112,53 +173,63 @@ object TorManager { try { desiredMode = mode lastMode = mode - val s = _status.value - if (mode == s.mode && mode != TorMode.OFF && (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)) { - Log.i(TAG, "applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip") + val s = _statusFlow.value + if (mode == s.mode && mode != TorMode.OFF && + (lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING) + ) { + Log.i( + TAG, + "applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip" + ) return } when (mode) { TorMode.OFF -> { Log.i(TAG, "applyMode: OFF -> stopping tor") lifecycleState = LifecycleState.STOPPING - _status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.STOPPING) - stopArti() // non-suspending immediate request - // Best-effort wait for STOPPED before we declare OFF + _statusFlow.value = _statusFlow.value.copy( + mode = TorMode.OFF, + running = false, + bootstrapPercent = 0, + state = TorState.STOPPING + ) + stopArti() waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS) socksAddr = null - _status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.OFF) + _statusFlow.value = _statusFlow.value.copy( + mode = TorMode.OFF, + running = false, + bootstrapPercent = 0, + state = TorState.OFF + ) 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) { } + resetNetworkConnections() } + TorMode.ON -> { Log.i(TAG, "applyMode: ON -> starting arti") - // Reset port to default unless we're already using a higher port if (currentSocksPort < DEFAULT_SOCKS_PORT) { currentSocksPort = DEFAULT_SOCKS_PORT } bindRetryAttempts = 0 lifecycleState = LifecycleState.STARTING - _status.value = _status.value.copy(mode = TorMode.ON, running = false, bootstrapPercent = 0, state = TorState.STARTING) - // Immediately set the planned SOCKS address so all traffic is forced through it, - // even before Tor is fully bootstrapped. This prevents any direct connections. + _statusFlow.value = _statusFlow.value.copy( + mode = TorMode.ON, + running = false, + bootstrapPercent = 0, + state = TorState.STARTING + ) socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort) - try { OkHttpProvider.reset() } catch (_: Throwable) { } - try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { } + resetNetworkConnections() startArti(application, useDelay = false) - // Defer enabling proxy until bootstrap completes appScope.launch { waitUntilBootstrapped() - if (_status.value.running && desiredMode == TorMode.ON) { + if (_statusFlow.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) { } + resetNetworkConnections() } } } @@ -171,84 +242,95 @@ object TorManager { private suspend fun startArti(application: Application, useDelay: Boolean = false) { try { - // Ensure any previous instance is fully stopped before starting a new one stopArtiAndWait() - Log.i(TAG, "Starting Arti on port $currentSocksPort…") if (useDelay) { delay(RESTART_DELAY_MS) } - val logListener = ArtiLogListener { logLine -> - val text = logLine ?: return@ArtiLogListener - val s = text.toString() - Log.i(TAG, "arti: $s") - lastLogTime.set(System.currentTimeMillis()) - _status.value = _status.value.copy(lastLogLine = s) - handleArtiLogLine(s) + val proxy = artiProxy ?: run { + Log.e(TAG, "ArtiProxy not initialized! This should not happen.") + _statusFlow.update { it.copy(state = TorState.ERROR) } + return } - val proxy = ArtiProxy.Builder(application) - .setSocksPort(currentSocksPort) - .setDnsPort(currentSocksPort + 1) - .setLogListener(logListener) - .build() - - artiProxyRef.set(proxy) proxy.start() lastLogTime.set(System.currentTimeMillis()) - _status.value = _status.value.copy(running = true, bootstrapPercent = 0, state = TorState.STARTING) + _statusFlow.update { + it.copy( + running = true, + bootstrapPercent = 0, + state = TorState.STARTING + ) + } lifecycleState = LifecycleState.RUNNING startInactivityMonitoring() - // Removed onion service startup (BLE-only file transfer in this branch) - } 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 + _statusFlow.update { it.copy(state = TorState.ERROR) } + val isBindError = isBindError(e) if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) { bindRetryAttempts++ currentSocksPort++ - 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 + Log.w( + TAG, + "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort" + ) 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 + resetNetworkConnections() startArti(application, useDelay = false) } else if (isBindError) { Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up") lifecycleState = LifecycleState.STOPPED - _status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.ERROR) + _statusFlow.update { + it.copy( + running = false, + bootstrapPercent = 0, + state = TorState.ERROR + ) + } } else { - // For non-bind errors, use the existing retry mechanism scheduleRetry(application) } } } - - /** - * Checks if the exception indicates a port binding failure - */ + private fun isBindError(exception: Exception): Boolean { val message = exception.message?.lowercase() ?: "" return message.contains("bind") || - message.contains("address already in use") || - message.contains("port") && message.contains("use") || - message.contains("permission denied") && message.contains("port") || - message.contains("could not bind") + message.contains("address already in use") || + message.contains("port") && message.contains("use") || + message.contains("permission denied") && message.contains("port") || + message.contains("could not bind") + } + + /** + * Reset network connections after Tor state changes. + * Rebuilds OkHttp clients and reconnects Nostr relays. + */ + private fun resetNetworkConnections() { + try { + OkHttpProvider.reset() + } catch (_: Throwable) { + } + try { + com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() + } catch (_: Throwable) { + } } private fun stopArtiInternal() { try { - val proxy = artiProxyRef.getAndSet(null) + val proxy = artiProxy if (proxy != null) { Log.i(TAG, "Stopping Arti…") - try { proxy.stop() } catch (_: Throwable) {} + try { + proxy.stop() + } catch (_: Throwable) { + } } stopInactivityMonitoring() stopRetryMonitoring() @@ -260,15 +342,16 @@ object TorManager { private fun stopArti() { stopArtiInternal() socksAddr = null - _status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.STOPPING) + _statusFlow.value = _statusFlow.value.copy( + running = false, + bootstrapPercent = 0, + state = TorState.STOPPING + ) } private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) { - // Request stop stopArtiInternal() - // Wait for confirmation via logs (Stopped) or timeout waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs) - // Small grace period before relaunch to let file locks clear delay(200) } @@ -276,7 +359,7 @@ object TorManager { Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...") stopArtiAndWait() delay(RESTART_DELAY_MS) - startArti(application, useDelay = false) // Already delayed above + startArti(application, useDelay = false) } private fun startInactivityMonitoring() { @@ -287,13 +370,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 + val currentMode = _statusFlow.value.mode if (currentMode == TorMode.ON) { - val bootstrapPercent = _status.value.bootstrapPercent + val bootstrapPercent = _statusFlow.value.bootstrapPercent if (bootstrapPercent < 100) { - Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti") + Log.w( + TAG, + "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti" + ) currentApplication?.let { app -> appScope.launch { restartArti(app) @@ -316,11 +402,11 @@ object TorManager { retryJob?.cancel() if (retryAttempts < MAX_RETRY_ATTEMPTS) { retryAttempts++ - val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) // Exponential backoff, max 30s + val delayMs = (1000L * (1 shl retryAttempts)).coerceAtMost(30000L) Log.w(TAG, "Scheduling Arti retry attempt $retryAttempts in ${delayMs}ms") retryJob = appScope.launch { delay(delayMs) - val currentMode = _status.value.mode + val currentMode = _statusFlow.value.mode if (currentMode == TorMode.ON) { Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)") restartArti(application) @@ -337,50 +423,110 @@ object TorManager { } private suspend fun waitUntilBootstrapped() { - val current = _status.value + val current = _statusFlow.value if (!current.running) return if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return - // Suspend until we observe RUNNING at least once while (true) { - val s = statusFlow.first { (it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) || !it.running || it.state == TorState.ERROR } + val s = statusFlow.first { + (it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) || + !it.running || + it.state == TorState.ERROR + } if (!s.running || s.state == TorState.ERROR) return if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return } } private fun handleArtiLogLine(s: String) { + val currentState = _statusFlow.value.state + val currentLifecycle = lifecycleState + when { s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> { - _status.value = _status.value.copy(state = TorState.STARTING) + if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) { + Log.w(TAG, "Ignoring stale 'Initialized' log (lifecycle: $currentLifecycle)") + return + } + _statusFlow.update { it.copy(state = TorState.STARTING) } completeWaitersIf(TorState.STARTING) } + s.contains("AMEx: state changed to Starting", ignoreCase = true) -> { - _status.value = _status.value.copy(state = TorState.STARTING) + if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) { + Log.w(TAG, "Ignoring stale 'Starting' log (lifecycle: $currentLifecycle)") + return + } + _statusFlow.update { it.copy(state = TorState.STARTING) } completeWaitersIf(TorState.STARTING) } - s.contains("Sufficiently bootstrapped; system SOCKS now functional", ignoreCase = true) -> { - _status.value = _status.value.copy(bootstrapPercent = 75, state = TorState.BOOTSTRAPPING) + + s.contains( + "Sufficiently bootstrapped; system SOCKS now functional", + ignoreCase = true + ) -> { + if (currentLifecycle != LifecycleState.RUNNING) { + Log.w(TAG, "Ignoring bootstrap log (lifecycle: $currentLifecycle)") + return + } + _statusFlow.update { + it.copy( + bootstrapPercent = 75, + state = TorState.BOOTSTRAPPING + ) + } retryAttempts = 0 bindRetryAttempts = 0 startInactivityMonitoring() } - //s.contains("AMEx: state changed to Running", ignoreCase = true) -> { + s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> { - // If we already saw Sufficiently bootstrapped, mark as RUNNING and ready. - val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready - _status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true) + if (currentLifecycle != LifecycleState.RUNNING) { + Log.w(TAG, "Ignoring guard discovery log (lifecycle: $currentLifecycle)") + return + } + _statusFlow.update { + it.copy( + state = TorState.RUNNING, + bootstrapPercent = 100, + running = true + ) + } completeWaitersIf(TorState.RUNNING) } + s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> { - _status.value = _status.value.copy(state = TorState.STOPPING, running = false) + if (currentLifecycle != LifecycleState.STOPPING) { + Log.w(TAG, "Ignoring stale 'Stopping' log (lifecycle: $currentLifecycle)") + return + } + _statusFlow.update { + it.copy( + state = TorState.STOPPING, + running = false + ) + } } + s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> { - _status.value = _status.value.copy(state = TorState.OFF, running = false, bootstrapPercent = 0) + if (currentLifecycle != LifecycleState.STOPPING && currentLifecycle != LifecycleState.STOPPED) { + Log.w( + TAG, + "Ignoring stale 'Stopped' log (lifecycle: $currentLifecycle, preventing state corruption)" + ) + return + } + _statusFlow.update { + it.copy( + state = TorState.OFF, + running = false, + bootstrapPercent = 0 + ) + } completeWaitersIf(TorState.OFF) } + s.contains("Another process has the lock on our state files", ignoreCase = true) -> { - // Signal error; we'll likely need to wait longer before restart - _status.value = _status.value.copy(state = TorState.ERROR) + _statusFlow.update { it.copy(state = TorState.ERROR) } } } } @@ -395,13 +541,11 @@ object TorManager { val def = CompletableDeferred() stateChangeDeferred.getAndSet(def)?.cancel() return withTimeoutOrNull(timeoutMs) { - // Fast-path: if we're already there - val cur = _status.value.state + val cur = _statusFlow.value.state if (cur == target) return@withTimeoutOrNull cur def.await() } } - // Visible for instrumentation tests to validate installation - fun installResourcesForTest(application: Application): Boolean { return true } + fun isTorAvailable(): Boolean = true } 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 45cff773..aafc0d10 100644 --- a/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt +++ b/app/src/main/java/com/bitchat/android/net/OkHttpProvider.kt @@ -42,8 +42,9 @@ object OkHttpProvider { private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder { val builder = OkHttpClient.Builder() - val socks: InetSocketAddress? = TorManager.currentSocksAddress() - // If a SOCKS address is defined, always use it. TorManager sets this as soon as Tor mode is ON, + val torProvider = ArtiTorManager.getInstance() + val socks: InetSocketAddress? = torProvider.currentSocksAddress() + // If a SOCKS address is defined, always use it. TorProvider sets this as soon as Tor mode is ON, // even before bootstrap, to prevent any direct connections from occurring. if (socks != null) { val proxy = Proxy(Proxy.Type.SOCKS, socks) diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt index c72c8e67..822606c2 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt @@ -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) { 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 655a9783..dc1a8e85 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -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>(emptyList()) - val notes: LiveData> = _notes + // Published state (StateFlow for Android) + private val _notes = MutableStateFlow>(emptyList()) + val notes: StateFlow> = _notes.asStateFlow() - private val _geohash = MutableLiveData(null) - val geohash: LiveData = _geohash + private val _geohash = MutableStateFlow(null) + val geohash: StateFlow = _geohash.asStateFlow() - private val _initialLoadComplete = MutableLiveData(false) - val initialLoadComplete: LiveData = _initialLoadComplete + private val _initialLoadComplete = MutableStateFlow(false) + val initialLoadComplete: StateFlow = _initialLoadComplete.asStateFlow() - private val _state = MutableLiveData(State.IDLE) - val state: LiveData = _state + private val _state = MutableStateFlow(State.IDLE) + val state: StateFlow = _state.asStateFlow() - private val _errorMessage = MutableLiveData(null) - val errorMessage: LiveData = _errorMessage + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() // Private state private var subscriptionIDs: MutableMap = mutableMapOf() 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 9e34c34c..a4803157 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -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() - val isInitialized: LiveData = _isInitialized + private val _isInitialized = MutableStateFlow(false) + val isInitialized: StateFlow = _isInitialized.asStateFlow() - private val _currentNpub = MutableLiveData() - val currentNpub: LiveData = _currentNpub + private val _currentNpub = MutableStateFlow(null) + val currentNpub: StateFlow = _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 = relayManager.isConnected + val relayConnectionStatus: StateFlow = relayManager.isConnected /** * Get relay information */ - val relayInfo: LiveData> = relayManager.relays + val relayInfo: StateFlow> = relayManager.relays // MARK: - Private Methods 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 eb5806bb..d44e6e0b 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -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>() - val relays: LiveData> = _relays + private val _relays = MutableStateFlow>(emptyList()) + val relays: StateFlow> = _relays.asStateFlow() - private val _isConnected = MutableLiveData() - val isConnected: LiveData = _isConnected + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() // Internal state private val relaysList = mutableListOf() @@ -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 { diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt new file mode 100644 index 00000000..d4e00104 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt @@ -0,0 +1,67 @@ +package com.bitchat.android.service + +import android.app.Application +import android.os.Process +import androidx.core.app.NotificationManagerCompat +import com.bitchat.android.mesh.BluetoothMeshService +import com.bitchat.android.net.ArtiTorManager +import com.bitchat.android.net.TorMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Coordinates a full application shutdown: + * - Stop mesh cleanly + * - Stop Tor without changing persistent setting + * - Clear in-memory AppState + * - Stop foreground service/notification + * - Kill the process after completion or after a 5s timeout + */ +object AppShutdownCoordinator { + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + fun requestFullShutdownAndKill( + app: Application, + mesh: BluetoothMeshService?, + notificationManager: NotificationManagerCompat, + stopForeground: () -> Unit, + stopService: () -> Unit + ) { + scope.launch { + // Stop mesh (best-effort) + try { mesh?.stopServices() } catch (_: Exception) { } + + // Stop Tor temporarily (do not change user setting) + val torProvider = ArtiTorManager.getInstance() + val torStop = async { + try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { } + } + + // Clear AppState in-memory store + try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { } + + // Stop foreground and clear notification + try { stopForeground() } catch (_: Exception) { } + try { notificationManager.cancel(10001) } catch (_: Exception) { } + + // Wait up to 5 seconds for shutdown tasks + withTimeoutOrNull(5000) { + try { torStop.await() } catch (_: Exception) { } + delay(100) + } + + // Stop the service itself + try { stopService() } catch (_: Exception) { } + + // Hard kill the app process + try { Process.killProcess(Process.myPid()) } catch (_: Exception) { } + try { System.exit(0) } catch (_: Exception) { } + } + } +} + diff --git a/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt b/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt new file mode 100644 index 00000000..03a9dcd8 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/BootCompletedReceiver.kt @@ -0,0 +1,16 @@ +package com.bitchat.android.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class BootCompletedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + // Ensure preferences are initialized on cold boot before reading values + try { MeshServicePreferences.init(context.applicationContext) } catch (_: Exception) { } + + if (MeshServicePreferences.isAutoStartEnabled(true)) { + MeshForegroundService.start(context.applicationContext) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt new file mode 100644 index 00000000..7510fa06 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt @@ -0,0 +1,324 @@ +package com.bitchat.android.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.bitchat.android.MainActivity +import com.bitchat.android.R +import com.bitchat.android.mesh.BluetoothMeshService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class MeshForegroundService : Service() { + + companion object { + private const val CHANNEL_ID = "bitchat_mesh_service" + private const val NOTIFICATION_ID = 10001 + + const val ACTION_START = "com.bitchat.android.service.START" + const val ACTION_STOP = "com.bitchat.android.service.STOP" + const val ACTION_QUIT = "com.bitchat.android.service.QUIT" + const val ACTION_UPDATE_NOTIFICATION = "com.bitchat.android.service.UPDATE_NOTIFICATION" + const val ACTION_NOTIFICATION_PERMISSION_GRANTED = "com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED" + + fun start(context: Context) { + val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_START } + + // On API >= 26, avoid background-service start restrictions by using startForegroundService + // only when we can actually post a notification (Android 13+ requires runtime notif permission) + val bgEnabled = MeshServicePreferences.isBackgroundEnabled(true) + val hasNotifPerm = hasNotificationPermissionStatic(context) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (bgEnabled && hasNotifPerm) { + context.startForegroundService(intent) + } else { + // Do not attempt to start a background service from headless context without notif permission + // or when background is disabled, to avoid BackgroundServiceStartNotAllowedException. + android.util.Log.i( + "MeshForegroundService", + "Not starting service on API>=26 (bgEnabled=$bgEnabled, hasNotifPerm=$hasNotifPerm)" + ) + } + } else { + if (bgEnabled) { + context.startService(intent) + } else { + android.util.Log.i("MeshForegroundService", "Background disabled; not starting service (pre-O)") + } + } + } + + /** + * Helper to be invoked right after POST_NOTIFICATIONS is granted to try + * promoting/starting the foreground service immediately without polling. + */ + fun onNotificationPermissionGranted(context: Context) { + // If background is enabled and permission now granted, start/promo service + val hasNotifPerm = hasNotificationPermissionStatic(context) + if (!MeshServicePreferences.isBackgroundEnabled(true) || !hasNotifPerm) return + + val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_UPDATE_NOTIFICATION } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Safe now that we can show a notification + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_STOP } + context.startService(intent) + } + + private fun shouldStartAsForeground(context: Context): Boolean { + return MeshServicePreferences.isBackgroundEnabled(true) && + hasBluetoothPermissionsStatic(context) && + hasNotificationPermissionStatic(context) + } + + private fun hasBluetoothPermissionsStatic(ctx: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else { + val fine = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + val coarse = androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + fine || coarse + } + } + + private fun hasNotificationPermissionStatic(ctx: Context): Boolean { + return if (Build.VERSION.SDK_INT >= 33) { + androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else true + } + } + + private lateinit var notificationManager: NotificationManagerCompat + private var updateJob: Job? = null + private var meshService: BluetoothMeshService? = null + private val serviceJob = Job() + private val scope = CoroutineScope(Dispatchers.Default + serviceJob) + private var isInForeground: Boolean = false + + override fun onCreate() { + super.onCreate() + notificationManager = NotificationManagerCompat.from(this) + createChannel() + + // Adopt or create the mesh service + val existing = MeshServiceHolder.meshService + meshService = existing ?: MeshServiceHolder.getOrCreate(applicationContext) + if (existing != null) { + android.util.Log.d("MeshForegroundService", "Adopted existing BluetoothMeshService from holder") + } else { + android.util.Log.i("MeshForegroundService", "Created/adopted new BluetoothMeshService via holder") + } + MeshServiceHolder.attach(meshService!!) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> { + // Stop FGS and mesh cleanly + try { meshService?.stopServices() } catch (_: Exception) { } + try { MeshServiceHolder.clear() } catch (_: Exception) { } + try { stopForeground(true) } catch (_: Exception) { } + notificationManager.cancel(NOTIFICATION_ID) + isInForeground = false + stopSelf() + return START_NOT_STICKY + } + ACTION_QUIT -> { + // Fully stop all background activity, stop Tor (without changing setting), then kill the app + AppShutdownCoordinator.requestFullShutdownAndKill( + app = application, + mesh = meshService, + notificationManager = notificationManager, + stopForeground = { + try { stopForeground(true) } catch (_: Exception) { } + isInForeground = false + }, + stopService = { stopSelf() } + ) + return START_NOT_STICKY + } + ACTION_UPDATE_NOTIFICATION -> { + // If we became eligible and are not in foreground yet, promote once + if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { + val n = buildNotification(meshService?.getActivePeerCount() ?: 0) + startForeground(NOTIFICATION_ID, n) + isInForeground = true + } else { + updateNotification(force = true) + } + } + else -> { /* ACTION_START or null */ } + } + + // Ensure mesh is running (only after permissions are granted) + ensureMeshStarted() + + // Promote exactly once when eligible, otherwise stay background (or stop) + if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) { + val notification = buildNotification(meshService?.getActivePeerCount() ?: 0) + startForeground(NOTIFICATION_ID, notification) + isInForeground = true + } + + // Periodically refresh the notification with live network size + if (updateJob == null) { + updateJob = scope.launch { + while (isActive) { + // Retry enabling mesh/foreground once permissions become available + ensureMeshStarted() + val eligible = MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() + if (eligible) { + // Only update the notification; do not re-call startForeground() + updateNotification(force = false) + } else { + // If disabled or perms missing, ensure we are not in foreground and clear notif + if (isInForeground) { + try { stopForeground(false) } catch (_: Exception) { } + isInForeground = false + } + notificationManager.cancel(NOTIFICATION_ID) + } + delay(5000) + } + } + } + + return START_STICKY + } + + private fun ensureMeshStarted() { + if (!hasBluetoothPermissions()) return + try { + android.util.Log.d("MeshForegroundService", "Ensuring mesh service is started") + meshService?.startServices() + } catch (e: Exception) { + android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}") + } + } + + private fun updateNotification(force: Boolean) { + val count = meshService?.getActivePeerCount() ?: 0 + val notification = buildNotification(count) + if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) { + notificationManager.notify(NOTIFICATION_ID, notification) + } else if (force) { + // If disabled and forced, make sure to remove any prior foreground state + try { stopForeground(false) } catch (_: Exception) { } + notificationManager.cancel(NOTIFICATION_ID) + isInForeground = false + } + } + + private fun hasAllRequiredPermissions(): Boolean { + // For starting FGS with connectedDevice|dataSync, we need: + // - Foreground service permissions (declared in manifest) + // - One of the device-related permissions (we request BL perms at runtime) + // - On Android 13+, POST_NOTIFICATIONS to actually show notification + return hasBluetoothPermissions() && hasNotificationPermission() + } + + private fun hasBluetoothPermissions(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADVERTISE) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED && + androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_SCAN) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else { + // Prior to S, scanning requires location permissions + val fine = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + val coarse = androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) == android.content.pm.PackageManager.PERMISSION_GRANTED + fine || coarse + } + } + + private fun hasNotificationPermission(): Boolean { + return if (Build.VERSION.SDK_INT >= 33) { + androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED + } else true + } + + private fun buildNotification(activeUsers: Int): Notification { + val openIntent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0) + ) + + // Action: Quit Bitchat + val quitIntent = Intent(this, MeshForegroundService::class.java).apply { action = ACTION_QUIT } + val quitPendingIntent = PendingIntent.getService( + this, 1, quitIntent, + PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0) + ) + + val title = getString(R.string.app_name) + val content = getString(R.string.mesh_service_notification_content, activeUsers) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(title) + .setContentText(content) + .setSmallIcon(R.mipmap.ic_launcher) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setContentIntent(pendingIntent) + // Add an action button that appears when notification is expanded + .addAction( + android.R.drawable.ic_menu_close_clear_cancel, + getString(R.string.notification_action_quit_bitchat), + quitPendingIntent + ) + .build() + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.mesh_service_channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.mesh_service_channel_desc) + setShowBadge(false) + } + (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .createNotificationChannel(channel) + } + } + + override fun onDestroy() { + updateJob?.cancel() + updateJob = null + // Cancel the service coroutine scope to prevent leaks + try { serviceJob.cancel() } catch (_: Exception) { } + // Best-effort ensure we are not marked foreground + if (isInForeground) { + try { stopForeground(true) } catch (_: Exception) { } + isInForeground = false + } + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt new file mode 100644 index 00000000..d271ab29 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/MeshServiceHolder.kt @@ -0,0 +1,60 @@ +package com.bitchat.android.service + +import android.content.Context +import com.bitchat.android.mesh.BluetoothMeshService + +/** + * Process-wide holder to share a single BluetoothMeshService instance + * between the foreground service and UI (MainActivity/ViewModels). + */ +object MeshServiceHolder { + private const val TAG = "MeshServiceHolder" + @Volatile + var meshService: BluetoothMeshService? = null + private set + + @Synchronized + fun getOrCreate(context: Context): BluetoothMeshService { + val existing = meshService + if (existing != null) { + // If the existing instance is healthy, reuse it; otherwise, replace it. + return try { + if (existing.isReusable()) { + android.util.Log.d(TAG, "Reusing existing BluetoothMeshService instance") + existing + } else { + android.util.Log.w(TAG, "Existing BluetoothMeshService not reusable; replacing with a fresh instance") + // Best-effort stop before replacing + try { existing.stopServices() } catch (e: Exception) { + android.util.Log.w(TAG, "Error while stopping non-reusable instance: ${e.message}") + } + val created = BluetoothMeshService(context.applicationContext) + android.util.Log.i(TAG, "Created new BluetoothMeshService (replacement)") + meshService = created + created + } + } catch (e: Exception) { + android.util.Log.e(TAG, "Error checking service reusability; creating new instance: ${e.message}") + val created = BluetoothMeshService(context.applicationContext) + meshService = created + created + } + } + val created = BluetoothMeshService(context.applicationContext) + android.util.Log.i(TAG, "Created new BluetoothMeshService (no existing instance)") + meshService = created + return created + } + + @Synchronized + fun attach(service: BluetoothMeshService) { + android.util.Log.d(TAG, "Attaching BluetoothMeshService to holder") + meshService = service + } + + @Synchronized + fun clear() { + android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder") + meshService = null + } +} diff --git a/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt b/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt new file mode 100644 index 00000000..47335ee3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/service/MeshServicePreferences.kt @@ -0,0 +1,32 @@ +package com.bitchat.android.service + +import android.content.Context +import android.content.SharedPreferences + +object MeshServicePreferences { + private const val PREFS_NAME = "bitchat_mesh_service_prefs" + private const val KEY_AUTO_START = "auto_start_on_boot" + private const val KEY_BACKGROUND_ENABLED = "background_enabled" + + private lateinit var prefs: SharedPreferences + + fun init(context: Context) { + prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + fun isAutoStartEnabled(default: Boolean = true): Boolean { + return prefs.getBoolean(KEY_AUTO_START, default) + } + + fun setAutoStartEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_AUTO_START, enabled).apply() + } + + fun isBackgroundEnabled(default: Boolean = true): Boolean { + return prefs.getBoolean(KEY_BACKGROUND_ENABLED, default) + } + + fun setBackgroundEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_BACKGROUND_ENABLED, enabled).apply() + } +} diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt new file mode 100644 index 00000000..07f146bd --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -0,0 +1,109 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Process-wide in-memory state store that survives Activity recreation. + * The foreground Mesh service updates this store; UI subscribes/hydrates from it. + */ +object AppStateStore { + // Global de-dup set by message id to avoid duplicate keys in Compose lists + private val seenMessageIds = mutableSetOf() + // Connected peer IDs (mesh ephemeral IDs) + private val _peers = MutableStateFlow>(emptyList()) + val peers: StateFlow> = _peers.asStateFlow() + + // Public mesh timeline messages (non-channel) + private val _publicMessages = MutableStateFlow>(emptyList()) + val publicMessages: StateFlow> = _publicMessages.asStateFlow() + + // Private messages by peerID + private val _privateMessages = MutableStateFlow>>(emptyMap()) + val privateMessages: StateFlow>> = _privateMessages.asStateFlow() + + // Channel messages by channel name + private val _channelMessages = MutableStateFlow>>(emptyMap()) + val channelMessages: StateFlow>> = _channelMessages.asStateFlow() + + fun setPeers(ids: List) { + _peers.value = ids + } + + fun addPublicMessage(msg: BitchatMessage) { + synchronized(this) { + if (seenMessageIds.contains(msg.id)) return + seenMessageIds.add(msg.id) + _publicMessages.value = _publicMessages.value + msg + } + } + + fun addPrivateMessage(peerID: String, msg: BitchatMessage) { + synchronized(this) { + if (seenMessageIds.contains(msg.id)) return + seenMessageIds.add(msg.id) + val map = _privateMessages.value.toMutableMap() + val list = (map[peerID] ?: emptyList()) + msg + map[peerID] = list + _privateMessages.value = map + } + } + + private fun statusPriority(status: DeliveryStatus?): Int = when (status) { + null -> 0 + is DeliveryStatus.Sending -> 1 + is DeliveryStatus.Sent -> 2 + is DeliveryStatus.PartiallyDelivered -> 3 + is DeliveryStatus.Delivered -> 4 + is DeliveryStatus.Read -> 5 + is DeliveryStatus.Failed -> 0 + } + + fun updatePrivateMessageStatus(messageID: String, status: DeliveryStatus) { + synchronized(this) { + val map = _privateMessages.value.toMutableMap() + var changed = false + map.keys.toList().forEach { peer -> + val list = map[peer]?.toMutableList() ?: mutableListOf() + val idx = list.indexOfFirst { it.id == messageID } + if (idx >= 0) { + val current = list[idx].deliveryStatus + // Do not downgrade (e.g., Read -> Delivered) + if (statusPriority(status) >= statusPriority(current)) { + list[idx] = list[idx].copy(deliveryStatus = status) + map[peer] = list + changed = true + } + } + } + if (changed) { + _privateMessages.value = map + } + } + } + + fun addChannelMessage(channel: String, msg: BitchatMessage) { + synchronized(this) { + if (seenMessageIds.contains(msg.id)) return + seenMessageIds.add(msg.id) + val map = _channelMessages.value.toMutableMap() + val list = (map[channel] ?: emptyList()) + msg + map[channel] = list + _channelMessages.value = map + } + } + + // Clear all in-memory state (used for full app shutdown) + fun clear() { + synchronized(this) { + seenMessageIds.clear() + _peers.value = emptyList() + _publicMessages.value = emptyList() + _privateMessages.value = emptyMap() + _channelMessages.value = emptyMap() + } + } +} diff --git a/app/src/main/java/com/bitchat/android/services/NicknameProvider.kt b/app/src/main/java/com/bitchat/android/services/NicknameProvider.kt new file mode 100644 index 00000000..c7cc601f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/NicknameProvider.kt @@ -0,0 +1,21 @@ +package com.bitchat.android.services + +import android.content.Context +import com.bitchat.android.ui.DataManager + +/** + * Provides current user's nickname for announcements and leave messages. + * If no nickname saved, falls back to the provided peerID. + */ +object NicknameProvider { + fun getNickname(context: Context, myPeerID: String): String { + return try { + val dm = DataManager(context.applicationContext) + val nick = dm.loadNickname() + if (nick.isNullOrBlank()) myPeerID else nick + } catch (_: Exception) { + myPeerID + } + } +} + 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 da8524cd..d931c027 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -12,11 +12,15 @@ 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 import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.NetworkCheck +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import kotlinx.coroutines.launch import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector @@ -29,12 +33,171 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.nostr.NostrProofOfWork import com.bitchat.android.nostr.PoWPreferenceManager -import com.bitchat.android.ui.debug.DebugSettingsSheet 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 + /** - * About Sheet for bitchat app information - * Matches the design language of LocationChannelsSheet + * Feature row for displaying app capabilities + */ +@Composable +private fun FeatureRow( + icon: ImageVector, + title: String, + subtitle: String +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(22.dp) + ) + Spacer(modifier = Modifier.width(14.dp)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + lineHeight = 18.sp + ) + } + } +} + +/** + * Theme selection chip with Apple-like styling + */ +@Composable +private fun ThemeChip( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + + Surface( + modifier = modifier, + onClick = onClick, + shape = RoundedCornerShape(10.dp), + color = if (selected) { + if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + } else { + colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) Color.White else colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + } +} + +/** + * Unified settings toggle row with icon, title, subtitle, and switch + * Apple-like design with proper spacing + */ +@Composable +private fun SettingsToggleRow( + icon: ImageVector, + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, + statusIndicator: (@Composable () -> Unit)? = null +) { + val colorScheme = MaterialTheme.colorScheme + val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (enabled) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.3f), + modifier = Modifier.size(22.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f) + ) + statusIndicator?.invoke() + } + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f), + lineHeight = 16.sp + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Switch( + checked = checked, + onCheckedChange = { if (enabled) onCheckedChange(it) }, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), + uncheckedThumbColor = Color.White, + uncheckedTrackColor = colorScheme.surfaceVariant + ) + ) + } +} + +/** + * Apple-like About/Settings Sheet with high-quality design + * Professional UX optimized for checkout scenarios */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -54,8 +217,6 @@ fun AboutSheet( "1.0.0" // fallback version } } - - // Bottom sheet state val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true @@ -68,11 +229,10 @@ fun AboutSheet( } } val topBarAlpha by animateFloatAsState( - targetValue = if (isScrolled) 0.95f else 0f, + targetValue = if (isScrolled) 0.98f else 0f, label = "topBarAlpha" ) - // Color scheme matching LocationChannelsSheet val colorScheme = MaterialTheme.colorScheme val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f @@ -81,402 +241,368 @@ fun AboutSheet( modifier = modifier.statusBarsPadding(), onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.background, + containerColor = colorScheme.background, dragHandle = null ) { Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( state = lazyListState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 80.dp, bottom = 20.dp) + contentPadding = PaddingValues(top = 80.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) ) { - // Header Section + // Header Section - App Identity item(key = "header") { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Bottom - ) { - Text( - text = stringResource(R.string.app_name), - style = TextStyle( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - fontSize = 32.sp - ), - color = MaterialTheme.colorScheme.onBackground - ) - - Text( - text = stringResource(R.string.version_prefix, versionName?:""), - fontSize = 11.sp, + Text( + text = stringResource(R.string.app_name), + style = TextStyle( fontFamily = FontFamily.Monospace, - color = colorScheme.onBackground.copy(alpha = 0.5f), - style = MaterialTheme.typography.bodySmall.copy( - baselineShift = BaselineShift(0.1f) - ) - ) - } - + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + letterSpacing = 1.sp + ), + color = colorScheme.onBackground + ) + Text( + text = stringResource(R.string.version_prefix, versionName ?: ""), + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onBackground.copy(alpha = 0.5f) + ) Text( text = stringResource(R.string.about_tagline), - fontSize = 12.sp, + fontSize = 13.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) + color = colorScheme.onBackground.copy(alpha = 0.6f), + modifier = Modifier.padding(top = 4.dp) ) } } - // Features section - item(key = "feature_offline") { - Row( - verticalAlignment = Alignment.Top, - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(vertical = 8.dp) - ) { - Icon( - imageVector = Icons.Filled.Bluetooth, - contentDescription = stringResource(R.string.cd_offline_mesh_chat), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(20.dp) + // Features Section - Grouped Card + item(key = "features") { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Text( + text = stringResource(R.string.about_appearance).uppercase(), + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onBackground.copy(alpha = 0.5f), + letterSpacing = 0.5.sp, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text( - text = stringResource(R.string.about_offline_mesh_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.about_offline_mesh_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) - ) - } - } - } - item(key = "feature_geohash") { - Row( - verticalAlignment = Alignment.Top, - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(vertical = 8.dp) - ) { - Icon( - imageVector = Icons.Default.Public, - contentDescription = stringResource(R.string.cd_online_geohash_channels), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(20.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text( - text = stringResource(R.string.about_online_geohash_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.about_online_geohash_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) - ) - } - } - } - item(key = "feature_encryption") { - Row( - verticalAlignment = Alignment.Top, - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(vertical = 8.dp) - ) { - Icon( - imageVector = Icons.Default.Lock, - contentDescription = stringResource(R.string.cd_end_to_end_encryption), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .padding(top = 2.dp) - .size(20.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text( - text = stringResource(R.string.about_e2e_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.about_e2e_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) - ) + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surface, + shape = RoundedCornerShape(16.dp) + ) { + Column { + FeatureRow( + icon = Icons.Filled.Bluetooth, + title = stringResource(R.string.about_offline_mesh_title), + subtitle = stringResource(R.string.about_offline_mesh_desc) + ) + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + FeatureRow( + icon = Icons.Default.Public, + title = stringResource(R.string.about_online_geohash_title), + subtitle = stringResource(R.string.about_online_geohash_desc) + ) + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + FeatureRow( + icon = Icons.Default.Lock, + title = stringResource(R.string.about_e2e_title), + subtitle = stringResource(R.string.about_e2e_desc) + ) + } } } } // Appearance Section - item(key = "appearance_section") { - Text( - text = stringResource(R.string.about_appearance), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(top = 24.dp, bottom = 8.dp) - ) - val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.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) }, - 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) }, - 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) }, - label = { Text(stringResource(R.string.about_dark), fontFamily = FontFamily.Monospace) } + item(key = "appearance") { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Text( + text = "THEME", + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onBackground.copy(alpha = 0.5f), + letterSpacing = 0.5.sp, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) ) + val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surface, + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ThemeChip( + label = stringResource(R.string.about_system), + selected = themePref.isSystem, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.about_light), + selected = themePref.isLight, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) }, + modifier = Modifier.weight(1f) + ) + ThemeChip( + label = stringResource(R.string.about_dark), + selected = themePref.isDark, + onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) }, + modifier = Modifier.weight(1f) + ) + } + } } } - // Proof of Work Section - item(key = "pow_section") { - Text( - text = stringResource(R.string.about_pow), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(top = 24.dp, bottom = 8.dp) - ) - LaunchedEffect(Unit) { - PoWPreferenceManager.init(context) - } + // Settings Section - Unified Card with Toggles + item(key = "settings") { + LaunchedEffect(Unit) { PoWPreferenceManager.init(context) } val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() + var backgroundEnabled by remember { mutableStateOf(com.bitchat.android.service.MeshServicePreferences.isBackgroundEnabled(true)) } + val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } + val torProvider = remember { ArtiTorManager.getInstance() } + val torStatus by torProvider.statusFlow.collectAsState() + val torAvailable = remember { torProvider.isTorAvailable() } - Column( - modifier = Modifier.padding(horizontal = 24.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Text( + text = "SETTINGS", + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onBackground.copy(alpha = 0.5f), + letterSpacing = 0.5.sp, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) + ) + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surface, + shape = RoundedCornerShape(16.dp) ) { - FilterChip( - selected = !powEnabled, - onClick = { PoWPreferenceManager.setPowEnabled(false) }, - label = { Text(stringResource(R.string.about_pow_off), fontFamily = FontFamily.Monospace) } - ) - FilterChip( - selected = powEnabled, - onClick = { PoWPreferenceManager.setPowEnabled(true) }, - label = { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(stringResource(R.string.about_pow_on), fontFamily = FontFamily.Monospace) - // Show current difficulty - if (powEnabled) { - Surface( - color = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), - shape = RoundedCornerShape(50) - ) { Box(Modifier.size(8.dp)) } + Column { + // Background Mode Toggle + SettingsToggleRow( + icon = Icons.Filled.Bluetooth, + title = stringResource(R.string.about_background_title), + subtitle = stringResource(R.string.about_background_desc), + checked = backgroundEnabled, + onCheckedChange = { enabled -> + backgroundEnabled = enabled + com.bitchat.android.service.MeshServicePreferences.setBackgroundEnabled(enabled) + if (!enabled) { + com.bitchat.android.service.MeshForegroundService.stop(context) + } else { + com.bitchat.android.service.MeshForegroundService.start(context) } } - } - ) - } - - Text( - text = stringResource(R.string.about_pow_tip), - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) - ) - - // Show difficulty slider when enabled - if (powEnabled) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = stringResource(R.string.about_pow_difficulty, powDifficulty, NostrProofOfWork.estimateMiningTime(powDifficulty)), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, ) - - Slider( - value = powDifficulty.toFloat(), - onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, - valueRange = 0f..32f, - steps = 33, - colors = SliderDefaults.colors( - thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), - activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - ) + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // Proof of Work Toggle + SettingsToggleRow( + icon = Icons.Filled.Speed, + title = stringResource(R.string.about_pow), + subtitle = stringResource(R.string.about_pow_tip), + checked = powEnabled, + onCheckedChange = { PoWPreferenceManager.setPowEnabled(it) } + ) + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + color = colorScheme.outline.copy(alpha = 0.12f) + ) + + // Tor Toggle + SettingsToggleRow( + icon = Icons.Filled.Security, + title = "Tor Network", + subtitle = stringResource(R.string.about_tor_route), + checked = torMode.value == TorMode.ON, + onCheckedChange = { enabled -> + if (torAvailable) { + torMode.value = if (enabled) TorMode.ON else TorMode.OFF + TorPreferenceManager.set(context, torMode.value) + } + }, + enabled = torAvailable, + statusIndicator = if (torMode.value == TorMode.ON) { + { + val statusColor = when { + torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + torStatus.running -> Color(0xFFFF9500) + else -> Color(0xFFFF3B30) + } + Surface( + color = statusColor, + shape = CircleShape, + modifier = Modifier.size(8.dp) + ) {} + } + } else null ) - - // Show difficulty description - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surfaceVariant.copy(alpha = 0.25f), - shape = RoundedCornerShape(8.dp) - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = stringResource(R.string.about_pow_difficulty_attempts, powDifficulty, NostrProofOfWork.estimateWork(powDifficulty)), - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.7f) - ) - Text( - text = when { - powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) - powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low) - powDifficulty <= 12 -> stringResource(R.string.about_pow_desc_low) - powDifficulty <= 16 -> stringResource(R.string.about_pow_desc_medium) - powDifficulty <= 20 -> stringResource(R.string.about_pow_desc_high) - powDifficulty <= 24 -> stringResource(R.string.about_pow_desc_very_high) - else -> stringResource(R.string.about_pow_desc_extreme) - }, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - } } } + + // Tor unavailable hint + if (!torAvailable) { + Text( + text = stringResource(R.string.tor_not_available_in_this_build), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onBackground.copy(alpha = 0.5f), + modifier = Modifier.padding(start = 16.dp, top = 8.dp) + ) + } } } - // 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() - Text( - text = stringResource(R.string.about_network), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier - .padding(horizontal = 24.dp) - .padding(top = 24.dp, bottom = 8.dp) - ) - Column(modifier = Modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - FilterChip( - selected = torMode.value == com.bitchat.android.net.TorMode.OFF, - onClick = { - torMode.value = com.bitchat.android.net.TorMode.OFF - com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value) - }, - label = { Text("tor off", fontFamily = FontFamily.Monospace) } - ) - FilterChip( - selected = torMode.value == com.bitchat.android.net.TorMode.ON, - onClick = { - torMode.value = com.bitchat.android.net.TorMode.ON - com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value) - }, - label = { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("tor on", fontFamily = FontFamily.Monospace) - val statusColor = when { - torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) - torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - else -> Color.Red - } - Surface(color = statusColor, shape = CircleShape) { - Box(Modifier.size(8.dp)) - } - } - } - ) - } - Text( - text = stringResource(R.string.about_tor_route), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - if (torMode.value == com.bitchat.android.net.TorMode.ON) { - val statusText = if (torStatus.running) "Running" else "Stopped" - // Debug status (temporary) + // PoW Difficulty Slider (when enabled) + item(key = "pow_slider") { + val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() + val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() + + if (powEnabled) { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { Surface( modifier = Modifier.fillMaxWidth(), - color = colorScheme.surfaceVariant.copy(alpha = 0.25f), - shape = RoundedCornerShape(8.dp) + color = colorScheme.surface, + shape = RoundedCornerShape(16.dp) ) { Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Text( - text = stringResource(R.string.about_tor_status, statusText, torStatus.bootstrapPercent), - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface.copy(alpha = 0.75f) - ) - val lastLog = torStatus.lastLogLine - if (lastLog.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { Text( - text = stringResource(R.string.about_last, lastLog.take(160)), - style = MaterialTheme.typography.labelSmall, + text = "Difficulty", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + Text( + text = "$powDifficulty bits • ${NostrProofOfWork.estimateMiningTime(powDifficulty)}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, color = colorScheme.onSurface.copy(alpha = 0.6f) ) } + + Slider( + value = powDifficulty.toFloat(), + onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, + valueRange = 0f..32f, + steps = 31, + colors = SliderDefaults.colors( + thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), + activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + ) + ) + + Text( + text = when { + powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none) + powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low) + powDifficulty <= 12 -> stringResource(R.string.about_pow_desc_low) + powDifficulty <= 16 -> stringResource(R.string.about_pow_desc_medium) + powDifficulty <= 20 -> stringResource(R.string.about_pow_desc_high) + powDifficulty <= 24 -> stringResource(R.string.about_pow_desc_very_high) + else -> stringResource(R.string.about_pow_desc_extreme) + }, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.5f) + ) + } + } + } + } + } + + // Tor Status (when enabled) + item(key = "tor_status") { + val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } + val torProvider = remember { ArtiTorManager.getInstance() } + val torStatus by torProvider.statusFlow.collectAsState() + + if (torMode.value == TorMode.ON) { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surface, + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val statusColor = when { + torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + torStatus.running -> Color(0xFFFF9500) + else -> Color(0xFFFF3B30) + } + Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {} + Text( + text = if (torStatus.running) "Connected (${torStatus.bootstrapPercent}%)" else "Disconnected", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface + ) + } + if (torStatus.lastLogLine.isNotEmpty()) { + Text( + text = torStatus.lastLogLine.take(120), + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.5f), + maxLines = 2 + ) + } } } } } } - // Emergency Warning Section - item(key = "warning_section") { - val colorScheme = MaterialTheme.colorScheme - val errorColor = colorScheme.error - + // Emergency Warning + item(key = "warning") { Surface( modifier = Modifier - .padding(horizontal = 24.dp, vertical = 24.dp) + .padding(horizontal = 20.dp) .fillMaxWidth(), - color = errorColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(12.dp) + color = colorScheme.error.copy(alpha = 0.1f), + shape = RoundedCornerShape(16.dp) ) { Row( modifier = Modifier.padding(16.dp), @@ -485,61 +611,53 @@ fun AboutSheet( ) { Icon( imageVector = Icons.Filled.Warning, - contentDescription = stringResource(R.string.cd_warning), - tint = errorColor, - modifier = Modifier.size(16.dp) + contentDescription = null, + tint = colorScheme.error, + modifier = Modifier.size(20.dp) ) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( text = stringResource(R.string.about_emergency_title), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - color = errorColor + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = colorScheme.error ) Text( text = stringResource(R.string.about_emergency_tip), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.8f) + fontSize = 13.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f) ) } } } } - // Footer Section + // Footer item(key = "footer") { Column( modifier = Modifier - .padding(horizontal = 24.dp) - .fillMaxWidth(), + .fillMaxWidth() + .padding(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { if (onShowDebug != null) { - TextButton( - onClick = onShowDebug, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - ) { + TextButton(onClick = onShowDebug) { Text( text = stringResource(R.string.about_debug_settings), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.primary ) } } Text( text = stringResource(R.string.about_footer), - fontSize = 11.sp, + fontSize = 12.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + color = colorScheme.onSurface.copy(alpha = 0.4f) ) - - // Add extra space at bottom for gesture area - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(20.dp)) } } } 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 4b1b22ca..f741dafa 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -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 @@ -28,9 +27,9 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.core.ui.utils.singleOrTripleClickable -import com.bitchat.android.geohash.LocationChannelManager.PermissionState import androidx.compose.foundation.Canvas import androidx.compose.ui.geometry.Offset +import androidx.lifecycle.compose.collectAsStateWithLifecycle /** * Header components for ChatScreen @@ -59,7 +58,8 @@ fun isFavoriteReactive( fun TorStatusDot( modifier: Modifier = Modifier ) { - val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState() + val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() } + val torStatus by torProvider.statusFlow.collectAsState() if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) { val dotColor = when { @@ -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 -> { 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 6ec8bef7..d4d33ac0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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 { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index e17c1494..6d0f2364 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -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>(emptyList()) - val messages: LiveData> = _messages + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() - private val _connectedPeers = MutableLiveData>(emptyList()) - val connectedPeers: LiveData> = _connectedPeers + private val _connectedPeers = MutableStateFlow>(emptyList()) + val connectedPeers: StateFlow> = _connectedPeers.asStateFlow() - private val _nickname = MutableLiveData() - val nickname: LiveData = _nickname + private val _nickname = MutableStateFlow("") + val nickname: StateFlow = _nickname.asStateFlow() - private val _isConnected = MutableLiveData(false) - val isConnected: LiveData = _isConnected + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() // Private chats - private val _privateChats = MutableLiveData>>(emptyMap()) - val privateChats: LiveData>> = _privateChats + private val _privateChats = MutableStateFlow>>(emptyMap()) + val privateChats: StateFlow>> = _privateChats.asStateFlow() - private val _selectedPrivateChatPeer = MutableLiveData(null) - val selectedPrivateChatPeer: LiveData = _selectedPrivateChatPeer + private val _selectedPrivateChatPeer = MutableStateFlow(null) + val selectedPrivateChatPeer: StateFlow = _selectedPrivateChatPeer.asStateFlow() - private val _unreadPrivateMessages = MutableLiveData>(emptySet()) - val unreadPrivateMessages: LiveData> = _unreadPrivateMessages + private val _unreadPrivateMessages = MutableStateFlow>(emptySet()) + val unreadPrivateMessages: StateFlow> = _unreadPrivateMessages.asStateFlow() // Channels - private val _joinedChannels = MutableLiveData>(emptySet()) - val joinedChannels: LiveData> = _joinedChannels + private val _joinedChannels = MutableStateFlow>(emptySet()) + val joinedChannels: StateFlow> = _joinedChannels.asStateFlow() - private val _currentChannel = MutableLiveData(null) - val currentChannel: LiveData = _currentChannel + private val _currentChannel = MutableStateFlow(null) + val currentChannel: StateFlow = _currentChannel.asStateFlow() - private val _channelMessages = MutableLiveData>>(emptyMap()) - val channelMessages: LiveData>> = _channelMessages + private val _channelMessages = MutableStateFlow>>(emptyMap()) + val channelMessages: StateFlow>> = _channelMessages.asStateFlow() - private val _unreadChannelMessages = MutableLiveData>(emptyMap()) - val unreadChannelMessages: LiveData> = _unreadChannelMessages + private val _unreadChannelMessages = MutableStateFlow>(emptyMap()) + val unreadChannelMessages: StateFlow> = _unreadChannelMessages.asStateFlow() - private val _passwordProtectedChannels = MutableLiveData>(emptySet()) - val passwordProtectedChannels: LiveData> = _passwordProtectedChannels + private val _passwordProtectedChannels = MutableStateFlow>(emptySet()) + val passwordProtectedChannels: StateFlow> = _passwordProtectedChannels.asStateFlow() - private val _showPasswordPrompt = MutableLiveData(false) - val showPasswordPrompt: LiveData = _showPasswordPrompt + private val _showPasswordPrompt = MutableStateFlow(false) + val showPasswordPrompt: StateFlow = _showPasswordPrompt.asStateFlow() - private val _passwordPromptChannel = MutableLiveData(null) - val passwordPromptChannel: LiveData = _passwordPromptChannel + private val _passwordPromptChannel = MutableStateFlow(null) + val passwordPromptChannel: StateFlow = _passwordPromptChannel.asStateFlow() // Sidebar state - private val _showSidebar = MutableLiveData(false) - val showSidebar: LiveData = _showSidebar + private val _showSidebar = MutableStateFlow(false) + val showSidebar: StateFlow = _showSidebar.asStateFlow() // Command autocomplete - private val _showCommandSuggestions = MutableLiveData(false) - val showCommandSuggestions: LiveData = _showCommandSuggestions + private val _showCommandSuggestions = MutableStateFlow(false) + val showCommandSuggestions: StateFlow = _showCommandSuggestions.asStateFlow() - private val _commandSuggestions = MutableLiveData>(emptyList()) - val commandSuggestions: LiveData> = _commandSuggestions + private val _commandSuggestions = MutableStateFlow>(emptyList()) + val commandSuggestions: StateFlow> = _commandSuggestions.asStateFlow() // Mention autocomplete - private val _showMentionSuggestions = MutableLiveData(false) - val showMentionSuggestions: LiveData = _showMentionSuggestions + private val _showMentionSuggestions = MutableStateFlow(false) + val showMentionSuggestions: StateFlow = _showMentionSuggestions.asStateFlow() - private val _mentionSuggestions = MutableLiveData>(emptyList()) - val mentionSuggestions: LiveData> = _mentionSuggestions + private val _mentionSuggestions = MutableStateFlow>(emptyList()) + val mentionSuggestions: StateFlow> = _mentionSuggestions.asStateFlow() // Favorites - private val _favoritePeers = MutableLiveData>(emptySet()) - val favoritePeers: LiveData> = _favoritePeers + private val _favoritePeers = MutableStateFlow>(emptySet()) + val favoritePeers: StateFlow> = _favoritePeers.asStateFlow() // Noise session states for peers (for reactive UI updates) - private val _peerSessionStates = MutableLiveData>(emptyMap()) - val peerSessionStates: LiveData> = _peerSessionStates + private val _peerSessionStates = MutableStateFlow>(emptyMap()) + val peerSessionStates: StateFlow> = _peerSessionStates.asStateFlow() // Peer fingerprint state for reactive favorites (for reactive UI updates) - private val _peerFingerprints = MutableLiveData>(emptyMap()) - val peerFingerprints: LiveData> = _peerFingerprints + private val _peerFingerprints = MutableStateFlow>(emptyMap()) + val peerFingerprints: StateFlow> = _peerFingerprints.asStateFlow() - private val _peerNicknames = MutableLiveData>(emptyMap()) - val peerNicknames: LiveData> = _peerNicknames + private val _peerNicknames = MutableStateFlow>(emptyMap()) + val peerNicknames: StateFlow> = _peerNicknames.asStateFlow() - private val _peerRSSI = MutableLiveData>(emptyMap()) - val peerRSSI: LiveData> = _peerRSSI + private val _peerRSSI = MutableStateFlow>(emptyMap()) + val peerRSSI: StateFlow> = _peerRSSI.asStateFlow() // Direct connection status per peer (for live UI updates) - private val _peerDirect = MutableLiveData>(emptyMap()) - val peerDirect: LiveData> = _peerDirect + private val _peerDirect = MutableStateFlow>(emptyMap()) + val peerDirect: StateFlow> = _peerDirect.asStateFlow() // peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager // Navigation state - private val _showAppInfo = MutableLiveData(false) - val showAppInfo: LiveData = _showAppInfo + private val _showAppInfo = MutableStateFlow(false) + val showAppInfo: StateFlow = _showAppInfo.asStateFlow() // Location channels state (for Nostr geohash features) - private val _selectedLocationChannel = MutableLiveData(com.bitchat.android.geohash.ChannelID.Mesh) - val selectedLocationChannel: LiveData = _selectedLocationChannel + private val _selectedLocationChannel = MutableStateFlow(com.bitchat.android.geohash.ChannelID.Mesh) + val selectedLocationChannel: StateFlow = _selectedLocationChannel.asStateFlow() - private val _isTeleported = MutableLiveData(false) - val isTeleported: LiveData = _isTeleported + private val _isTeleported = MutableStateFlow(false) + val isTeleported: StateFlow = _isTeleported.asStateFlow() // Geohash people state (iOS-compatible) - private val _geohashPeople = MutableLiveData>(emptyList()) - val geohashPeople: LiveData> = _geohashPeople - // For background thread updates by repositories/handlers in their own scopes - val geohashPeopleMutable: MutableLiveData> get() = _geohashPeople + private val _geohashPeople = MutableStateFlow>(emptyList()) + val geohashPeople: StateFlow> = _geohashPeople.asStateFlow() - - private val _teleportedGeo = MutableLiveData>(emptySet()) - val teleportedGeo: LiveData> = _teleportedGeo + private val _teleportedGeo = MutableStateFlow>(emptySet()) + val teleportedGeo: StateFlow> = _teleportedGeo.asStateFlow() // Geohash participant counts reactive state (for real-time location channel counts) - private val _geohashParticipantCounts = MutableLiveData>(emptyMap()) - val geohashParticipantCounts: LiveData> = _geohashParticipantCounts + private val _geohashParticipantCounts = MutableStateFlow>(emptyMap()) + val geohashParticipantCounts: StateFlow> = _geohashParticipantCounts.asStateFlow() - // Unread state computed properties - val hasUnreadChannels: MediatorLiveData = MediatorLiveData() - val hasUnreadPrivateMessages: MediatorLiveData = MediatorLiveData() + + val hasUnreadChannels: StateFlow = _unreadChannelMessages + .map { unreadMap -> unreadMap.values.any { it > 0 } } + .stateIn( + scope = scope, + started = WhileSubscribed(5_000), + initialValue = false + ) - init { - // Initialize unread state mediators - hasUnreadChannels.addSource(_unreadChannelMessages) { unreadMap -> - hasUnreadChannels.value = unreadMap.values.any { it > 0 } - } - - hasUnreadPrivateMessages.addSource(_unreadPrivateMessages) { unreadSet -> - hasUnreadPrivateMessages.value = unreadSet.isNotEmpty() - } - } + val hasUnreadPrivateMessages: StateFlow = _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) { - _geohashPeople.postValue(people) - } - - fun postGeohashParticipantCounts(counts: Map) { - _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) { @@ -197,7 +195,7 @@ class ChatState { } fun postTeleportedGeo(teleported: Set) { - _teleportedGeo.postValue(teleported) + _teleportedGeo.value = teleported } fun setNickname(nickname: String) { @@ -269,7 +267,7 @@ class ChatState { } fun setFavoritePeers(favorites: Set) { - 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) { 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 74ab15c6..1196396d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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() @@ -103,44 +105,79 @@ class ChatViewModel( - // Expose state through LiveData (maintaining the same interface) - val messages: LiveData> = state.messages - val connectedPeers: LiveData> = state.connectedPeers - val nickname: LiveData = state.nickname - val isConnected: LiveData = state.isConnected - val privateChats: LiveData>> = state.privateChats - val selectedPrivateChatPeer: LiveData = state.selectedPrivateChatPeer - val unreadPrivateMessages: LiveData> = state.unreadPrivateMessages - val joinedChannels: LiveData> = state.joinedChannels - val currentChannel: LiveData = state.currentChannel - val channelMessages: LiveData>> = state.channelMessages - val unreadChannelMessages: LiveData> = state.unreadChannelMessages - val passwordProtectedChannels: LiveData> = state.passwordProtectedChannels - val showPasswordPrompt: LiveData = state.showPasswordPrompt - val passwordPromptChannel: LiveData = state.passwordPromptChannel - val showSidebar: LiveData = state.showSidebar + + val messages: StateFlow> = state.messages + val connectedPeers: StateFlow> = state.connectedPeers + val nickname: StateFlow = state.nickname + val isConnected: StateFlow = state.isConnected + val privateChats: StateFlow>> = state.privateChats + val selectedPrivateChatPeer: StateFlow = state.selectedPrivateChatPeer + val unreadPrivateMessages: StateFlow> = state.unreadPrivateMessages + val joinedChannels: StateFlow> = state.joinedChannels + val currentChannel: StateFlow = state.currentChannel + val channelMessages: StateFlow>> = state.channelMessages + val unreadChannelMessages: StateFlow> = state.unreadChannelMessages + val passwordProtectedChannels: StateFlow> = state.passwordProtectedChannels + val showPasswordPrompt: StateFlow = state.showPasswordPrompt + val passwordPromptChannel: StateFlow = state.passwordPromptChannel + val showSidebar: StateFlow = state.showSidebar val hasUnreadChannels = state.hasUnreadChannels val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages - val showCommandSuggestions: LiveData = state.showCommandSuggestions - val commandSuggestions: LiveData> = state.commandSuggestions - val showMentionSuggestions: LiveData = state.showMentionSuggestions - val mentionSuggestions: LiveData> = state.mentionSuggestions - val favoritePeers: LiveData> = state.favoritePeers - val peerSessionStates: LiveData> = state.peerSessionStates - val peerFingerprints: LiveData> = state.peerFingerprints - val peerNicknames: LiveData> = state.peerNicknames - val peerRSSI: LiveData> = state.peerRSSI - val peerDirect: LiveData> = state.peerDirect - val showAppInfo: LiveData = state.showAppInfo - val selectedLocationChannel: LiveData = state.selectedLocationChannel - val isTeleported: LiveData = state.isTeleported - val geohashPeople: LiveData> = state.geohashPeople - val teleportedGeo: LiveData> = state.teleportedGeo - val geohashParticipantCounts: LiveData> = state.geohashParticipantCounts + val showCommandSuggestions: StateFlow = state.showCommandSuggestions + val commandSuggestions: StateFlow> = state.commandSuggestions + val showMentionSuggestions: StateFlow = state.showMentionSuggestions + val mentionSuggestions: StateFlow> = state.mentionSuggestions + val favoritePeers: StateFlow> = state.favoritePeers + val peerSessionStates: StateFlow> = state.peerSessionStates + val peerFingerprints: StateFlow> = state.peerFingerprints + val peerNicknames: StateFlow> = state.peerNicknames + val peerRSSI: StateFlow> = state.peerRSSI + val peerDirect: StateFlow> = state.peerDirect + val showAppInfo: StateFlow = state.showAppInfo + val selectedLocationChannel: StateFlow = state.selectedLocationChannel + val isTeleported: StateFlow = state.isTeleported + val geohashPeople: StateFlow> = state.geohashPeople + val teleportedGeo: StateFlow> = state.teleportedGeo + val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts init { // Note: Mesh service delegate is now set by MainActivity loadAndInitialize() + // Hydrate UI state from process-wide AppStateStore to survive Activity recreation + viewModelScope.launch { + try { com.bitchat.android.services.AppStateStore.peers.collect { peers -> + state.setConnectedPeers(peers) + state.setIsConnected(peers.isNotEmpty()) + } } catch (_: Exception) { } + } + viewModelScope.launch { + try { com.bitchat.android.services.AppStateStore.publicMessages.collect { msgs -> + // Source of truth is AppStateStore; replace to avoid duplicate keys in LazyColumn + state.setMessages(msgs) + } } catch (_: Exception) { } + } + viewModelScope.launch { + try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer -> + // Replace with store snapshot + state.setPrivateChats(byPeer) + // Recompute unread set using SeenMessageStore for robustness across Activity recreation + try { + val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()) + val myNick = state.getNicknameValue() ?: meshService.myPeerID + val unread = mutableSetOf() + byPeer.forEach { (peer, list) -> + if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer) + } + state.setUnreadPrivateMessages(unread) + } catch (_: Exception) { } + } } catch (_: Exception) { } + } + viewModelScope.launch { + try { com.bitchat.android.services.AppStateStore.channelMessages.collect { byChannel -> + // Replace with store snapshot + state.setChannelMessages(byChannel) + } } catch (_: Exception) { } + } // Subscribe to BLE transfer progress and reflect in message deliveryStatus viewModelScope.launch { com.bitchat.android.mesh.TransferProgressManager.events.collect { evt -> @@ -565,7 +602,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", "==============================") diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 1b0837a2..0bdc85ee 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -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 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 060bb84e..52e00edf 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -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> = state.geohashPeople - val geohashParticipantCounts: LiveData> = state.geohashParticipantCounts - val selectedLocationChannel: LiveData = state.selectedLocationChannel + val geohashPeople: StateFlow> = state.geohashPeople + val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts + val selectedLocationChannel: StateFlow = 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}") } 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 0555e74f..6f47d68f 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -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("") } 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 09084a92..601d4c03 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -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) 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 23b9d952..461796d3 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -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() } 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 9d4da4c9..d17b6d7c 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt @@ -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] diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt index 1ea59444..9c7d776f 100644 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt @@ -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) } 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 d6a9e746..a452616e 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -46,10 +46,10 @@ class MeshDelegateHandler( if (message.isPrivate) { // Private message privateChatManager.handleIncomingPrivateMessage(message) - - // Reactive read receipts: Send immediately if user is currently viewing this chat + + // Reactive read receipts: if chat is focused, send immediately for this message message.senderPeerID?.let { senderPeerID -> - sendReadReceiptIfFocused(senderPeerID) + sendReadReceiptIfFocused(message) } // Show notification with enhanced information - now includes senderPeerID @@ -64,15 +64,26 @@ class MeshDelegateHandler( ) } } else if (message.channel != null) { - // Channel message + // Channel message: AppStateStore is the source of truth for list; only manage unread if (state.getJoinedChannelsValue().contains(message.channel)) { - channelManager.addChannelMessage(message.channel, message, message.senderPeerID) + val channel = message.channel + val viewingClassic = state.getCurrentChannelValue() == channel + val viewingGeohash = try { + if (channel.startsWith("geo:")) { + val geo = channel.removePrefix("geo:") + val selected = state.selectedLocationChannel.value + selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true) + } else false + } catch (_: Exception) { false } + if (!viewingClassic && !viewingGeohash) { + val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap() + currentUnread[channel] = (currentUnread[channel] ?: 0) + 1 + state.setUnreadChannelMessages(currentUnread) + } } } else { - // Public mesh message - always store to preserve message history - messageManager.addMessage(message) - - // Check for mentions in mesh chat + // Public mesh message: AppStateStore is the source of truth; avoid double-adding to UI state + // Still run mention detection/notifications checkAndTriggerMeshMentionNotification(message) } @@ -263,21 +274,31 @@ class MeshDelegateHandler( * Uses same logic as notification system - send read receipt if user is currently * viewing the private chat with this sender AND app is in foreground. */ - private fun sendReadReceiptIfFocused(senderPeerID: String) { + private fun sendReadReceiptIfFocused(message: BitchatMessage) { // Get notification manager's focus state (mirror the notification logic) val isAppInBackground = notificationManager.getAppBackgroundState() val currentPrivateChatPeer = notificationManager.getCurrentPrivateChatPeer() // Send read receipt if user is currently focused on this specific chat - val shouldSendReadReceipt = !isAppInBackground && currentPrivateChatPeer == senderPeerID + val senderPeerID = message.senderPeerID + val shouldSendReadReceipt = !isAppInBackground && senderPeerID != null && currentPrivateChatPeer == senderPeerID - if (shouldSendReadReceipt) { - android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID") - privateChatManager.sendReadReceiptsForPeer(senderPeerID, getMeshService()) - } else { - android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") + if (shouldSendReadReceipt) { + android.util.Log.d("MeshDelegateHandler", "Sending reactive read receipt for focused chat with $senderPeerID (message=${message.id})") + val nickname = state.getNicknameValue() ?: "unknown" + // Send directly for this message to avoid relying on unread queues + getMeshService().sendReadReceipt(message.id, senderPeerID!!, nickname) + // Ensure unread badge is cleared for this peer immediately + try { + val current = state.getUnreadPrivateMessagesValue().toMutableSet() + if (current.remove(senderPeerID)) { + state.setUnreadPrivateMessages(current) + } + } catch (_: Exception) { } + } else { + android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)") + } } - } // registerPeerPublicKey REMOVED - fingerprints now handled centrally in PeerManager diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index f35689e7..40b7eb98 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -22,6 +22,8 @@ class MessageManager(private val state: ChatState) { val currentMessages = state.getMessagesValue().toMutableList() currentMessages.add(message) state.setMessages(currentMessages) + // Reflect into process-wide store so snapshot replacements don't drop local outgoing messages + try { com.bitchat.android.services.AppStateStore.addPublicMessage(message) } catch (_: Exception) { } } // Log a system message into the main chat (visible to user) @@ -52,6 +54,8 @@ class MessageManager(private val state: ChatState) { channelMessageList.add(message) currentChannelMessages[channel] = channelMessageList state.setChannelMessages(currentChannelMessages) + // Reflect into process-wide store + try { com.bitchat.android.services.AppStateStore.addChannelMessage(channel, message) } catch (_: Exception) { } // Update unread count if not currently viewing this channel // Consider both classic channels (state.currentChannel) and geohash location channel selection @@ -105,6 +109,8 @@ class MessageManager(private val state: ChatState) { chatMessages.add(message) currentPrivateChats[peerID] = chatMessages state.setPrivateChats(currentPrivateChats) + // Reflect into process-wide store + try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { } // Mark as unread if not currently viewing this chat if (state.getSelectedPrivateChatPeerValue() != peerID && message.sender != state.getNicknameValue()) { @@ -124,6 +130,8 @@ class MessageManager(private val state: ChatState) { chatMessages.add(message) currentPrivateChats[peerID] = chatMessages state.setPrivateChats(currentPrivateChats) + // Reflect into process-wide store + try { com.bitchat.android.services.AppStateStore.addPrivateMessage(peerID, message) } catch (_: Exception) { } } fun clearPrivateMessages(peerID: String) { @@ -206,6 +214,21 @@ class MessageManager(private val state: ChatState) { // MARK: - Delivery Status Updates + private fun statusPriority(status: DeliveryStatus?): Int = when (status) { + null -> 0 + is DeliveryStatus.Sending -> 1 + is DeliveryStatus.Sent -> 2 + is DeliveryStatus.PartiallyDelivered -> 3 + is DeliveryStatus.Delivered -> 4 + is DeliveryStatus.Read -> 5 + is DeliveryStatus.Failed -> 0 // treat as lowest for UI check marks ordering + } + + private fun chooseStatus(old: DeliveryStatus?, new: DeliveryStatus): DeliveryStatus? { + // Never downgrade (e.g., Read -> Delivered). Keep the higher priority. + return if (statusPriority(new) >= statusPriority(old)) new else old + } + fun updateMessageDeliveryStatus(messageID: String, status: DeliveryStatus) { // Update in private chats val updatedPrivateChats = state.getPrivateChatsValue().toMutableMap() @@ -215,22 +238,32 @@ class MessageManager(private val state: ChatState) { val updatedMessages = messages.toMutableList() val messageIndex = updatedMessages.indexOfFirst { it.id == messageID } if (messageIndex >= 0) { - updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status) - updatedPrivateChats[peerID] = updatedMessages - updated = true + val current = updatedMessages[messageIndex].deliveryStatus + val finalStatus = chooseStatus(current, status) + if (finalStatus !== current) { + updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus) + updatedPrivateChats[peerID] = updatedMessages + updated = true + } } } if (updated) { state.setPrivateChats(updatedPrivateChats) + // Keep process-wide store in sync to prevent snapshot overwrites resetting status + try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(messageID, status) } catch (_: Exception) { } } // Update in main messages val updatedMessages = state.getMessagesValue().toMutableList() val messageIndex = updatedMessages.indexOfFirst { it.id == messageID } if (messageIndex >= 0) { - updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = status) - state.setMessages(updatedMessages) + val current = updatedMessages[messageIndex].deliveryStatus + val finalStatus = chooseStatus(current, status) + if (finalStatus !== current) { + updatedMessages[messageIndex] = updatedMessages[messageIndex].copy(deliveryStatus = finalStatus) + state.setMessages(updatedMessages) + } } // Update in channel messages @@ -239,8 +272,12 @@ class MessageManager(private val state: ChatState) { val channelMessagesList = messages.toMutableList() val channelMessageIndex = channelMessagesList.indexOfFirst { it.id == messageID } if (channelMessageIndex >= 0) { - channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = status) - updatedChannelMessages[channel] = channelMessagesList + val current = channelMessagesList[channelMessageIndex].deliveryStatus + val finalStatus = chooseStatus(current, status) + if (finalStatus !== current) { + channelMessagesList[channelMessageIndex] = channelMessagesList[channelMessageIndex].copy(deliveryStatus = finalStatus) + updatedChannelMessages[channel] = channelMessagesList + } } } state.setChannelMessages(updatedChannelMessages) 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 b0d55128..883b2b45 100644 --- a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt +++ b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt @@ -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 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 e8a3a461..098a83d7 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -292,28 +292,30 @@ class PrivateChatManager( } fun handleIncomingPrivateMessage(message: BitchatMessage, suppressUnread: Boolean) { - message.senderPeerID?.let { senderPeerID -> + val senderPeerID = message.senderPeerID + if (senderPeerID != null) { + // Mesh-origin private message: AppStateStore updates the list; avoid double-add here. if (!isPeerBlocked(senderPeerID)) { - // Add to private messages - if (suppressUnread) { - messageManager.addPrivateMessageNoUnread(senderPeerID, message) - } else { - messageManager.addPrivateMessage(senderPeerID, message) - } - - // Track as unread for read receipt purposes - var unreadCount = 0 - if (!suppressUnread) { + // Ensure chat exists + messageManager.initializePrivateChat(senderPeerID) + // Track as unread for read receipt purposes if not focused + if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != senderPeerID) { val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() } unreadList.add(message) - unreadCount = unreadList.size + Log.d(TAG, "Queued unread from $senderPeerID (count=${unreadList.size})") + val currentUnread = state.getUnreadPrivateMessagesValue().toMutableSet() + currentUnread.add(senderPeerID) + state.setUnreadPrivateMessages(currentUnread) } - - Log.d( - TAG, - "Added received message ${message.id} from $senderPeerID to unread list (${unreadCount} unread)" - ) } + return + } + // Non-mesh path (e.g., Nostr): add to UI state using existing logic + val inferredPeer = state.getSelectedPrivateChatPeerValue() ?: return + if (suppressUnread) { + messageManager.addPrivateMessageNoUnread(inferredPeer, message) + } else { + messageManager.addPrivateMessage(inferredPeer, message) } } @@ -322,27 +324,33 @@ class PrivateChatManager( * Called when the user focuses on a private chat */ fun sendReadReceiptsForPeer(peerID: String, meshService: BluetoothMeshService) { - val unreadList = unreadReceivedMessages[peerID] - if (unreadList.isNullOrEmpty()) { - Log.d(TAG, "No unread messages to send read receipts for peer $peerID") - return + // Collect candidate messages: all incoming messages from this peer in the conversation + val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap>() } + val messages = chats[peerID].orEmpty() + + if (messages.isEmpty()) { + Log.d(TAG, "No messages found for peer $peerID to send read receipts") } - Log.d(TAG, "Sending read receipts for ${unreadList.size} unread messages from $peerID") - - // Send read receipt for each unread message - now using direct method call - unreadList.forEach { message -> - try { - val myNickname = state.getNicknameValue() ?: "unknown" - meshService.sendReadReceipt(message.id, peerID, myNickname) - Log.d(TAG, "Sent read receipt for message ${message.id} to $peerID") - } catch (e: Exception) { - Log.w(TAG, "Failed to send read receipt for message ${message.id}: ${e.message}") + val myNickname = state.getNicknameValue() ?: "unknown" + var sentCount = 0 + messages.forEach { msg -> + // Only for incoming messages from this peer + if (msg.senderPeerID == peerID) { + try { + meshService.sendReadReceipt(msg.id, peerID, myNickname) + sentCount += 1 + } catch (e: Exception) { + Log.w(TAG, "Failed to send read receipt for message ${msg.id}: ${e.message}") + } } } - // Clear the unread list since we've sent read receipts + // Clear any locally tracked unread queue for this peer unreadReceivedMessages.remove(peerID) + // Also clear UI unread marker for this peer now that chat is focused/read + try { messageManager.clearPrivateUnreadMessages(peerID) } catch (_: Exception) { } + Log.d(TAG, "Sent $sentCount read receipts for peer $peerID (from conversation messages)") } fun cleanupDisconnectedPeer(peerID: String) { 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 a8c59ef7..4ab4360a 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -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, 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 e2f9d966..04ad48a2 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 @@ -20,6 +20,7 @@ object DebugPreferenceManager { // GCS keys (no migration/back-compat) private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes" private const val KEY_GCS_FPR = "gcs_filter_fpr_percent" + // Removed: persistent notification toggle is now governed by MeshServicePreferences.isBackgroundEnabled private lateinit var prefs: SharedPreferences @@ -100,4 +101,6 @@ object DebugPreferenceManager { fun setGcsFprPercent(value: Double) { if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply() } + + // No longer storing persistent notification in debug prefs. } 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 6e37286f..77f6ce12 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 @@ -36,6 +36,11 @@ class DebugSettingsManager private constructor() { private val _packetRelayEnabled = MutableStateFlow(true) val packetRelayEnabled: StateFlow = _packetRelayEnabled.asStateFlow() + // Visibility of the debug sheet; gates heavy work + private val _debugSheetVisible = MutableStateFlow(false) + val debugSheetVisible: StateFlow = _debugSheetVisible.asStateFlow() + fun setDebugSheetVisible(visible: Boolean) { _debugSheetVisible.value = visible } + // Connection limit overrides (debug) private val _maxConnectionsOverall = MutableStateFlow(8) val maxConnectionsOverall: StateFlow = _maxConnectionsOverall.asStateFlow() @@ -75,12 +80,63 @@ class DebugSettingsManager private constructor() { // Timestamps to compute rolling window stats private val relayTimestamps = ConcurrentLinkedQueue() + // Per-device and per-peer rolling timestamps for stacked graphs + private val perDeviceRelayTimestamps = mutableMapOf>() + private val perPeerRelayTimestamps = mutableMapOf>() + + // Additional buckets to split incoming vs outgoing + private val incomingTimestamps = ConcurrentLinkedQueue() + private val outgoingTimestamps = ConcurrentLinkedQueue() + private val perDeviceIncoming = mutableMapOf>() + private val perDeviceOutgoing = mutableMapOf>() + private val perPeerIncoming = mutableMapOf>() + private val perPeerOutgoing = mutableMapOf>() + + // Expose current per-second rates (updated when logging/pruning occurs) + private val _perDeviceLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceLastSecond: StateFlow> = _perDeviceLastSecond.asStateFlow() + private val _perPeerLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerLastSecond: StateFlow> = _perPeerLastSecond.asStateFlow() + // New flows used by UI for incoming/outgoing stacked plots + private val _perDeviceIncomingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceIncomingLastSecond: StateFlow> = _perDeviceIncomingLastSecond.asStateFlow() + private val _perDeviceOutgoingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceOutgoingLastSecond: StateFlow> = _perDeviceOutgoingLastSecond.asStateFlow() + private val _perPeerIncomingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerIncomingLastSecond: StateFlow> = _perPeerIncomingLastSecond.asStateFlow() + private val _perPeerOutgoingLastSecond: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerOutgoingLastSecond: StateFlow> = _perPeerOutgoingLastSecond.asStateFlow() + + // Per-minute counts per key + private val _perDeviceIncomingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceIncomingLastMinute: StateFlow> = _perDeviceIncomingLastMinute.asStateFlow() + private val _perDeviceOutgoingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceOutgoingLastMinute: StateFlow> = _perDeviceOutgoingLastMinute.asStateFlow() + private val _perPeerIncomingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerIncomingLastMinute: StateFlow> = _perPeerIncomingLastMinute.asStateFlow() + private val _perPeerOutgoingLastMinute: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerOutgoingLastMinute: StateFlow> = _perPeerOutgoingLastMinute.asStateFlow() + + // Totals per key (since app start) + private val deviceIncomingTotalsMap = mutableMapOf() + private val deviceOutgoingTotalsMap = mutableMapOf() + private val peerIncomingTotalsMap = mutableMapOf() + private val peerOutgoingTotalsMap = mutableMapOf() + private val _perDeviceIncomingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceIncomingTotal: StateFlow> = _perDeviceIncomingTotalsFlow.asStateFlow() + private val _perDeviceOutgoingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perDeviceOutgoingTotal: StateFlow> = _perDeviceOutgoingTotalsFlow.asStateFlow() + private val _perPeerIncomingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerIncomingTotal: StateFlow> = _perPeerIncomingTotalsFlow.asStateFlow() + private val _perPeerOutgoingTotalsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) + val perPeerOutgoingTotal: StateFlow> = _perPeerOutgoingTotalsFlow.asStateFlow() // Internal data storage for managing debug data private val debugMessageQueue = ConcurrentLinkedQueue() private val scanResultsQueue = ConcurrentLinkedQueue() private fun updateRelayStatsFromTimestamps() { + if (!_debugSheetVisible.value) return val now = System.currentTimeMillis() // prune older than 15m while (true) { @@ -89,18 +145,84 @@ class DebugSettingsManager private constructor() { relayTimestamps.poll() } else break } + // prune per-device and per-peer and compute 1s/60s rates + fun pruneAndCount1s(map: MutableMap>): Map { + val result = mutableMapOf() + val iterator = map.entries.iterator() + while (iterator.hasNext()) { + val (key, q) = iterator.next() + // prune this queue + while (true) { + val ts = q.peek() ?: break + if (now - ts > 15 * 60 * 1000L) { + q.poll() + } else break + } + // count last 1s only + val count1s = q.count { now - it <= 1_000L } + if (q.isEmpty()) { + // cleanup empty queues to prevent unbounded growth + iterator.remove() + } + if (count1s > 0) result[key] = count1s + } + return result + } + fun pruneAndCount60s(map: MutableMap>): Map { + val result = mutableMapOf() + map.forEach { (key, q) -> + val count60 = q.count { now - it <= 60_000L } + if (count60 > 0) result[key] = count60 + } + return result + } + + val perDevice1s = pruneAndCount1s(perDeviceRelayTimestamps) + val perPeer1s = pruneAndCount1s(perPeerRelayTimestamps) + + _perDeviceLastSecond.value = perDevice1s + _perPeerLastSecond.value = perPeer1s + // Also compute incoming/outgoing per-key rates + _perDeviceIncomingLastSecond.value = pruneAndCount1s(perDeviceIncoming) + _perDeviceOutgoingLastSecond.value = pruneAndCount1s(perDeviceOutgoing) + _perPeerIncomingLastSecond.value = pruneAndCount1s(perPeerIncoming) + _perPeerOutgoingLastSecond.value = pruneAndCount1s(perPeerOutgoing) + _perDeviceIncomingLastMinute.value = pruneAndCount60s(perDeviceIncoming) + _perDeviceOutgoingLastMinute.value = pruneAndCount60s(perDeviceOutgoing) + _perPeerIncomingLastMinute.value = pruneAndCount60s(perPeerIncoming) + _perPeerOutgoingLastMinute.value = pruneAndCount60s(perPeerOutgoing) val last1s = relayTimestamps.count { now - it <= 1_000L } val last10s = relayTimestamps.count { now - it <= 10_000L } val last1m = relayTimestamps.count { now - it <= 60_000L } val last15m = relayTimestamps.size - val total = _relayStats.value.totalRelaysCount + 1 + // And incoming/outgoing per-second counters + val last1sIncoming = incomingTimestamps.count { now - it <= 1_000L } + val last1sOutgoing = outgoingTimestamps.count { now - it <= 1_000L } + val last10sIncoming = incomingTimestamps.count { now - it <= 10_000L } + val last10sOutgoing = outgoingTimestamps.count { now - it <= 10_000L } + val last1mIncoming = incomingTimestamps.count { now - it <= 60_000L } + val last1mOutgoing = outgoingTimestamps.count { now - it <= 60_000L } + val last15mIncoming = incomingTimestamps.size + val last15mOutgoing = outgoingTimestamps.size + val totalIncoming = _relayStats.value.totalIncomingCount + val totalOutgoing = _relayStats.value.totalOutgoingCount _relayStats.value = PacketRelayStats( - totalRelaysCount = total, + totalRelaysCount = totalIncoming + totalOutgoing, lastSecondRelays = last1s, last10SecondRelays = last10s, lastMinuteRelays = last1m, last15MinuteRelays = last15m, - lastResetTime = _relayStats.value.lastResetTime + lastResetTime = _relayStats.value.lastResetTime, + lastSecondIncoming = last1sIncoming, + lastSecondOutgoing = last1sOutgoing, + last10SecondIncoming = last10sIncoming, + last10SecondOutgoing = last10sOutgoing, + lastMinuteIncoming = last1mIncoming, + lastMinuteOutgoing = last1mOutgoing, + last15MinuteIncoming = last15mIncoming, + last15MinuteOutgoing = last15mOutgoing, + totalIncomingCount = totalIncoming, + totalOutgoingCount = totalOutgoing ) } @@ -336,11 +458,61 @@ class DebugSettingsManager private constructor() { } } - // Update rolling statistics only for relays - if (isRelay) { - relayTimestamps.offer(System.currentTimeMillis()) - updateRelayStatsFromTimestamps() + // Do not update counters here; this path is for readable logs only. + } + + // Explicit incoming/outgoing logging to avoid double counting + fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?) { + if (verboseLoggingEnabled.value) { + val who = fromNickname ?: fromPeerID ?: "unknown" + addDebugMessage(DebugMessage.PacketEvent("📥 Incoming $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})")) } + val now = System.currentTimeMillis() + val visible = _debugSheetVisible.value + if (visible) incomingTimestamps.offer(now) + fromDeviceAddress?.let { + perDeviceIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) + deviceIncomingTotalsMap[it] = (deviceIncomingTotalsMap[it] ?: 0L) + 1L + _perDeviceIncomingTotalsFlow.value = deviceIncomingTotalsMap.toMap() + } + fromPeerID?.let { + perPeerIncoming.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) + peerIncomingTotalsMap[it] = (peerIncomingTotalsMap[it] ?: 0L) + 1L + _perPeerIncomingTotalsFlow.value = peerIncomingTotalsMap.toMap() + } + // bump totals + val cur = _relayStats.value + _relayStats.value = cur.copy( + totalIncomingCount = cur.totalIncomingCount + 1, + totalRelaysCount = cur.totalRelaysCount + 1 + ) + if (visible) updateRelayStatsFromTimestamps() + } + + fun logOutgoing(packetType: String, toPeerID: String?, toNickname: String?, toDeviceAddress: String?, previousHopPeerID: String? = null) { + if (verboseLoggingEnabled.value) { + val who = toNickname ?: toPeerID ?: "unknown" + addDebugMessage(DebugMessage.PacketEvent("📤 Outgoing $packetType to $who (${toPeerID ?: "?"}, ${toDeviceAddress ?: "?"})")) + } + val now = System.currentTimeMillis() + val visible = _debugSheetVisible.value + if (visible) outgoingTimestamps.offer(now) + toDeviceAddress?.let { + perDeviceOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) + deviceOutgoingTotalsMap[it] = (deviceOutgoingTotalsMap[it] ?: 0L) + 1L + _perDeviceOutgoingTotalsFlow.value = deviceOutgoingTotalsMap.toMap() + } + (toPeerID ?: previousHopPeerID)?.let { + perPeerOutgoing.getOrPut(it) { ConcurrentLinkedQueue() }.offer(now) + peerOutgoingTotalsMap[it] = (peerOutgoingTotalsMap[it] ?: 0L) + 1L + _perPeerOutgoingTotalsFlow.value = peerOutgoingTotalsMap.toMap() + } + val cur = _relayStats.value + _relayStats.value = cur.copy( + totalOutgoingCount = cur.totalOutgoingCount + 1, + totalRelaysCount = cur.totalRelaysCount + 1 + ) + if (visible) updateRelayStatsFromTimestamps() } // MARK: - Clear Data @@ -407,5 +579,15 @@ data class PacketRelayStats( val last10SecondRelays: Int = 0, val lastMinuteRelays: Int = 0, val last15MinuteRelays: Int = 0, - val lastResetTime: Date = Date() + val lastResetTime: Date = Date(), + val lastSecondIncoming: Int = 0, + val lastSecondOutgoing: Int = 0, + val last10SecondIncoming: Int = 0, + val last10SecondOutgoing: Int = 0, + val lastMinuteIncoming: Int = 0, + val lastMinuteOutgoing: Int = 0, + val last15MinuteIncoming: Int = 0, + val last15MinuteOutgoing: Int = 0, + val totalIncomingCount: Long = 0, + val totalOutgoingCount: Long = 0 ) 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 6cd25547..4b9f983a 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 @@ -3,9 +3,11 @@ package com.bitchat.android.ui.debug import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.BugReport @@ -15,6 +17,7 @@ import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material.icons.filled.SettingsEthernet import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -27,8 +30,13 @@ import com.bitchat.android.mesh.BluetoothMeshService import kotlinx.coroutines.launch import androidx.compose.ui.res.stringResource import com.bitchat.android.R +import androidx.compose.ui.platform.LocalContext +import com.bitchat.android.service.MeshServicePreferences +import com.bitchat.android.service.MeshForegroundService -@OptIn(ExperimentalMaterial3Api::class) +private enum class GraphMode { OVERALL, PER_DEVICE, PER_PEER } + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun DebugSettingsSheet( isPresented: Boolean, @@ -53,6 +61,8 @@ fun DebugSettingsSheet( val seenCapacity by manager.seenPacketCapacity.collectAsState() val gcsMaxBytes by manager.gcsMaxBytes.collectAsState() val gcsFpr by manager.gcsFprPercent.collectAsState() + val context = LocalContext.current + // Persistent notification is now controlled solely by MeshServicePreferences.isBackgroundEnabled // Push live connected devices from mesh service whenever sheet is visible LaunchedEffect(isPresented) { @@ -89,6 +99,11 @@ fun DebugSettingsSheet( onDismissRequest = onDismiss, sheetState = sheetState ) { + // Mark debug sheet visible/invisible to gate heavy work + LaunchedEffect(Unit) { DebugSettingsManager.getInstance().setDebugSheetVisible(true) } + DisposableEffect(Unit) { + onDispose { DebugSettingsManager.getInstance().setDebugSheetVisible(false) } + } LazyColumn( modifier = Modifier .fillMaxWidth() @@ -202,88 +217,256 @@ fun DebugSettingsSheet( item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Persistent notification is controlled by About sheet (MeshServicePreferences.isBackgroundEnabled) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500)) Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium) Spacer(Modifier.weight(1f)) Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) }) } - Text(stringResource(R.string.debug_since_start_fmt, relayStats.totalRelaysCount), fontFamily = FontFamily.Monospace, fontSize = 11.sp) - Text(stringResource(R.string.debug_relays_window_fmt, relayStats.last10SecondRelays, relayStats.lastMinuteRelays, relayStats.last15MinuteRelays), fontFamily = FontFamily.Monospace, fontSize = 11.sp) - // Realtime graph: per-second relays, full-width canvas, bottom-up bars, fast decay - var series by remember { mutableStateOf(List(60) { 0f }) } - LaunchedEffect(isPresented) { + // Removed aggregate labels; we will show per-direction compact labels below titles + // Toggle: overall vs per-connection vs per-peer + var graphMode by rememberSaveable { mutableStateOf(GraphMode.OVERALL) } + val perDeviceIncoming by manager.perDeviceIncomingLastSecond.collectAsState() + val perPeerIncoming by manager.perPeerIncomingLastSecond.collectAsState() + val perDeviceOutgoing by manager.perDeviceOutgoingLastSecond.collectAsState() + val perPeerOutgoing by manager.perPeerOutgoingLastSecond.collectAsState() + val perDeviceIncoming1m by manager.perDeviceIncomingLastMinute.collectAsState() + val perDeviceOutgoing1m by manager.perDeviceOutgoingLastMinute.collectAsState() + val perPeerIncoming1m by manager.perPeerIncomingLastMinute.collectAsState() + val perPeerOutgoing1m by manager.perPeerOutgoingLastMinute.collectAsState() + val perDeviceIncomingTotal by manager.perDeviceIncomingTotal.collectAsState() + val perDeviceOutgoingTotal by manager.perDeviceOutgoingTotal.collectAsState() + val perPeerIncomingTotal by manager.perPeerIncomingTotal.collectAsState() + val perPeerOutgoingTotal by manager.perPeerOutgoingTotal.collectAsState() + val nicknameMap = remember { mutableStateOf>(emptyMap()) } + val devicePeerMap = remember { mutableStateOf>(emptyMap()) } + LaunchedEffect(Unit) { + try { nicknameMap.value = meshService.getPeerNicknames() } catch (_: Exception) { } + // Try to fetch device->peer map periodically for legend resolution while (isPresented) { - val s = relayStats.lastSecondRelays.toFloat() - val last = series.lastOrNull() ?: 0f - // Faster decay and smoothing - val v = last * 0.5f + s * 0.5f - series = (series + v).takeLast(60) - kotlinx.coroutines.delay(400) + try { devicePeerMap.value = meshService.getDeviceAddressToPeerMapping() } catch (_: Exception) { } + kotlinx.coroutines.delay(1000) } } - val maxValRaw = series.maxOrNull() ?: 0f - val maxVal = if (maxValRaw > 0f) maxValRaw else 0f - val leftGutter = 40.dp - Box(Modifier.fillMaxWidth().height(56.dp)) { - // Graph canvas - androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) { - val axisPx = leftGutter.toPx() // reserved left gutter for labels - val barCount = series.size - val availW = (size.width - axisPx).coerceAtLeast(1f) - val w = availW / barCount - val h = size.height - // Baseline at bottom (y = 0) - drawLine( - color = Color(0x33888888), - start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f), - end = androidx.compose.ui.geometry.Offset(size.width, h - 1f), - strokeWidth = 1f + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Mode selector + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = graphMode == GraphMode.OVERALL, + onClick = { graphMode = GraphMode.OVERALL }, + label = { Text("Overall") } ) - // Bars from bottom-up; skip zeros entirely - series.forEachIndexed { i, value -> - if (value > 0f && maxVal > 0f) { - val ratio = (value / maxVal).coerceIn(0f, 1f) - val barHeight = (h * ratio).coerceAtLeast(0f) - if (barHeight > 0.5f) { - drawRect( - color = Color(0xFF00C851), - topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight), - size = androidx.compose.ui.geometry.Size(w, barHeight) - ) + FilterChip( + selected = graphMode == GraphMode.PER_DEVICE, + onClick = { graphMode = GraphMode.PER_DEVICE }, + label = { Text("Per Device") }, + leadingIcon = { Icon(Icons.Filled.Devices, contentDescription = null) } + ) + FilterChip( + selected = graphMode == GraphMode.PER_PEER, + onClick = { graphMode = GraphMode.PER_PEER }, + label = { Text("Per Peer") }, + leadingIcon = { Icon(Icons.Filled.SettingsEthernet, contentDescription = null) } + ) + } + + // Time series state + var overallSeriesIncoming by rememberSaveable { mutableStateOf(List(60) { 0f }) } + var overallSeriesOutgoing by rememberSaveable { mutableStateOf(List(60) { 0f }) } + var stackedKeysIncoming by rememberSaveable { mutableStateOf(listOf()) } + var stackedKeysOutgoing by rememberSaveable { mutableStateOf(listOf()) } + var stackedSeriesIncoming by rememberSaveable { mutableStateOf>>(emptyMap()) } + var stackedSeriesOutgoing by rememberSaveable { mutableStateOf>>(emptyMap()) } + var highlightedKey by rememberSaveable { mutableStateOf(null) } + + // Color palette for stacked legend + val palette = remember { + listOf( + Color(0xFF00C851), Color(0xFF007AFF), Color(0xFFFF9500), Color(0xFFFF3B30), + Color(0xFF5AC8FA), Color(0xFFAF52DE), Color(0xFFFF2D55), Color(0xFF34C759), + Color(0xFFFFCC00), Color(0xFF5856D6) + ) + } + val colorForKey = remember { mutableStateMapOf() } + fun stableColorFor(key: String): Color { + // Deterministic fallback color based on key hash using HSV palette + val h = (key.hashCode().toUInt().toInt() and 0x7FFFFFFF) % 360 + return Color.hsv(h.toFloat(), 0.65f, 0.95f) + } + // Ensure colors are assigned for current keys before drawing + fun ensureColors(keys: List) { + keys.forEachIndexed { idx, k -> + colorForKey.putIfAbsent(k, palette.getOrNull(idx) ?: stableColorFor(k)) + } + } + + LaunchedEffect(isPresented, graphMode) { + while (isPresented) { + when (graphMode) { + GraphMode.OVERALL -> { + val sIn = relayStats.lastSecondIncoming.toFloat() + val sOut = relayStats.lastSecondOutgoing.toFloat() + overallSeriesIncoming = (overallSeriesIncoming + sIn).takeLast(60) + overallSeriesOutgoing = (overallSeriesOutgoing + sOut).takeLast(60) + } + GraphMode.PER_DEVICE -> { + val snapshotIn = perDeviceIncoming + val snapshotOut = perDeviceOutgoing + fun advance(base: Map>, snap: Map): Map> { + val next = mutableMapOf>() + val union = (base.keys + snap.keys).toSet() + union.forEach { k -> + val prev = base[k] ?: List(60) { 0f } + val s = (snap[k] ?: 0).toFloat() + next[k] = (prev + s).takeLast(60) + } + return next + } + // Advance and prune fully-stale series (all-zero in visible window) + stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } } + stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } } + stackedKeysIncoming = stackedSeriesIncoming.keys.sorted() + stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted() + } + GraphMode.PER_PEER -> { + val snapshotIn = perPeerIncoming + val snapshotOut = perPeerOutgoing + fun advance(base: Map>, snap: Map): Map> { + val next = mutableMapOf>() + val union = (base.keys + snap.keys).toSet() + union.forEach { k -> + val prev = base[k] ?: List(60) { 0f } + val s = (snap[k] ?: 0).toFloat() + next[k] = (prev + s).takeLast(60) + } + return next + } + stackedSeriesIncoming = advance(stackedSeriesIncoming, snapshotIn).filterValues { series -> series.any { it != 0f } } + stackedSeriesOutgoing = advance(stackedSeriesOutgoing, snapshotOut).filterValues { series -> series.any { it != 0f } } + stackedKeysIncoming = stackedSeriesIncoming.keys.sorted() + stackedKeysOutgoing = stackedSeriesOutgoing.keys.sorted() } } + kotlinx.coroutines.delay(1000) } } - // Left gutter layout: unit + ticks neatly aligned - Row(Modifier.fillMaxSize()) { - Box(Modifier.width(leftGutter).fillMaxHeight()) { - // Unit label on the far left, centered vertically - Text( - "p/s", - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - color = colorScheme.onSurface.copy(alpha = 0.7f), - modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f) - ) - // Tick labels right-aligned in gutter, top and bottom aligned - Text( - "${maxVal.toInt()}", - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - color = colorScheme.onSurface.copy(alpha = 0.7f), - modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp, top = 0.dp) - ) - Text( - "0", - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - color = colorScheme.onSurface.copy(alpha = 0.7f), - modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp, bottom = 0.dp) - ) + + // Helper functions moved to top-level composable below to avoid scope issues + + // Render two blocks: Incoming and Outgoing + Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text( + "${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}", + fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + DrawGraphBlock( + title = "Incoming", + stackedKeys = stackedKeysIncoming, + stackedSeries = stackedSeriesIncoming, + overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesIncoming else null, + graphMode = graphMode, + highlightedKey = highlightedKey, + onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key }, + ensureColors = { keys -> ensureColors(keys) }, + colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) }, + legendTitleFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val nick = nicknameMap.value[key] + val prefix = key.take(6) + if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix + } + GraphMode.PER_DEVICE -> { + val device = key + val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID + ?: devicePeerMap.value[device] + if (pid != null) { + val nick = nicknameMap.value[pid] + val prefix = pid.take(6) + "$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})" + } else device + } + else -> key + } + }, + legendMetricsFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val s = perPeerIncoming[key] ?: 0 + val m = perPeerIncoming1m[key] ?: 0 + val t = (perPeerIncomingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + GraphMode.PER_DEVICE -> { + val s = perDeviceIncoming[key] ?: 0 + val m = perDeviceIncoming1m[key] ?: 0 + val t = (perDeviceIncomingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + else -> "" + } } - Spacer(Modifier.weight(1f)) - } + ) + if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ } + + Spacer(Modifier.height(8.dp)) + Text("Outgoing", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Text( + "${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}", + fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + DrawGraphBlock( + title = "Outgoing", + stackedKeys = stackedKeysOutgoing, + stackedSeries = stackedSeriesOutgoing, + overallSeries = if (graphMode == GraphMode.OVERALL) overallSeriesOutgoing else null, + graphMode = graphMode, + highlightedKey = highlightedKey, + onToggleHighlight = { key -> highlightedKey = if (highlightedKey == key) null else key }, + ensureColors = { keys -> ensureColors(keys) }, + colorForKey = { k -> colorForKey[k] ?: stableColorFor(k) }, + legendTitleFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val nick = nicknameMap.value[key] + val prefix = key.take(6) + if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix + } + GraphMode.PER_DEVICE -> { + val device = key + val pid = connectedDevices.firstOrNull { it.deviceAddress == device }?.peerID + ?: devicePeerMap.value[device] + if (pid != null) { + val nick = nicknameMap.value[pid] + val prefix = pid.take(6) + "$device (${if (!nick.isNullOrBlank()) "$nick ($prefix)" else prefix})" + } else device + } + else -> key + } + }, + legendMetricsFor = { key -> + when (graphMode) { + GraphMode.PER_PEER -> { + val s = perPeerOutgoing[key] ?: 0 + val m = perPeerOutgoing1m[key] ?: 0 + val t = (perPeerOutgoingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + GraphMode.PER_DEVICE -> { + val s = perDeviceOutgoing[key] ?: 0 + val m = perDeviceOutgoing1m[key] ?: 0 + val t = (perDeviceOutgoingTotal[key] ?: 0L) + "${s}/s • ${m}/m • total ${t}" + } + else -> "" + } + } + ) + if (graphMode != GraphMode.OVERALL && stackedKeysOutgoing.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ } } } } @@ -399,3 +582,146 @@ fun DebugSettingsSheet( } } } + +@Composable +private fun DrawGraphBlock( + title: String, + stackedKeys: List, + stackedSeries: Map>, + overallSeries: List?, + graphMode: GraphMode, + highlightedKey: String?, + onToggleHighlight: (String) -> Unit, + ensureColors: (List) -> Unit, + colorForKey: (String) -> Color, + legendTitleFor: (String) -> String, + legendMetricsFor: (String) -> String +) { + val colorScheme = MaterialTheme.colorScheme + val leftGutter = 40.dp + Box(Modifier.fillMaxWidth().height(56.dp)) { + androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) { + val axisPx = leftGutter.toPx() + val barCount = 60 + val availW = (size.width - axisPx).coerceAtLeast(1f) + val w = availW / barCount + val h = size.height + drawLine( + color = Color(0x33888888), + start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f), + end = androidx.compose.ui.geometry.Offset(size.width, h - 1f), + strokeWidth = 1f + ) + + when (graphMode) { + GraphMode.OVERALL -> { + val maxValRaw = (overallSeries?.maxOrNull() ?: 0f) + val maxVal = if (maxValRaw > 0f) maxValRaw else 0f + (overallSeries ?: emptyList()).forEachIndexed { i, value -> + if (value > 0f && maxVal > 0f) { + val ratio = (value / maxVal).coerceIn(0f, 1f) + val barHeight = (h * ratio).coerceAtLeast(0f) + if (barHeight > 0.5f) { + drawRect( + color = Color(0xFF00C851), + topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight), + size = androidx.compose.ui.geometry.Size(w, barHeight) + ) + } + } + } + } + else -> { + val indices = 0 until 60 + val totals = indices.map { idx -> + stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat() + } + val maxTotal = (totals.maxOrNull() ?: 0f) + val drawKeysBars = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted() + indices.forEach { i -> + var yTop = h + if (maxTotal > 0f) { + ensureColors(drawKeysBars) + drawKeysBars.forEach { k -> + val v = stackedSeries[k]?.getOrNull(i) ?: 0f + if (v > 0f) { + val ratio = (v / maxTotal).coerceIn(0f, 1f) + val segH = (h * ratio) + if (segH > 0.5f) { + val top = (yTop - segH) + val baseColor = colorForKey(k) + val c = if (highlightedKey == null || highlightedKey == k) baseColor else baseColor.copy(alpha = 0.35f) + drawRect( + color = c, + topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = top), + size = androidx.compose.ui.geometry.Size(w, segH) + ) + yTop = top + } + } + } + } + } + } + } + } + + Row(Modifier.fillMaxSize()) { + Box(Modifier.width(leftGutter).fillMaxHeight()) { + Text( + "p/s", + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f) + ) + val topLabel = when (graphMode) { + GraphMode.OVERALL -> (overallSeries?.maxOrNull() ?: 0f).toInt().toString() + else -> { + val totals = (0 until 60).map { idx -> stackedSeries.values.sumOf { it.getOrNull(idx)?.toDouble() ?: 0.0 }.toFloat() } + (totals.maxOrNull() ?: 0f).toInt().toString() + } + } + Text( + topLabel, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp) + ) + Text( + "0", + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp) + ) + } + Spacer(Modifier.weight(1f)) + } + } + + val drawKeys = if (stackedKeys.isNotEmpty()) stackedKeys else stackedSeries.keys.sorted() + if (graphMode != GraphMode.OVERALL && drawKeys.isNotEmpty()) { + Column(Modifier.fillMaxWidth()) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + drawKeys.forEach { key -> + val baseColor = colorForKey(key) + val dimmed = highlightedKey != null && highlightedKey != key + val swatchColor = if (dimmed) baseColor.copy(alpha = 0.35f) else baseColor + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.clickable { onToggleHighlight(key) } + ) { + Box(Modifier.size(10.dp).background(swatchColor, RoundedCornerShape(2.dp))) + Column { + Text(legendTitleFor(key), fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f)) + Text(legendMetricsFor(key), fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f)) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index a332fca2..9881cc2f 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -80,8 +80,8 @@ object AppConstants { const val SCAN_ON_DURATION_ULTRA_LOW_MS: Long = 1_000L const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 10_000L const val MAX_CONNECTIONS_NORMAL: Int = 8 - const val MAX_CONNECTIONS_POWER_SAVE: Int = 4 - const val MAX_CONNECTIONS_ULTRA_LOW: Int = 2 + const val MAX_CONNECTIONS_POWER_SAVE: Int = 8 + const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4 } object Nostr { diff --git a/app/src/main/java/info/guardianproject/arti/ArtiException.kt b/app/src/main/java/info/guardianproject/arti/ArtiException.kt new file mode 100644 index 00000000..bee87ff3 --- /dev/null +++ b/app/src/main/java/info/guardianproject/arti/ArtiException.kt @@ -0,0 +1,9 @@ +package info.guardianproject.arti + +/** + * Exception thrown by Arti operations. + * + * This exception is thrown when the native Arti library encounters + * an error during initialization, startup, or other operations. + */ +class ArtiException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/app/src/main/java/info/guardianproject/arti/ArtiLogListener.kt b/app/src/main/java/info/guardianproject/arti/ArtiLogListener.kt new file mode 100644 index 00000000..d21d3989 --- /dev/null +++ b/app/src/main/java/info/guardianproject/arti/ArtiLogListener.kt @@ -0,0 +1,17 @@ +package info.guardianproject.arti + +/** + * Listener interface for Arti log messages. + * + * This interface is called from the native Arti implementation whenever + * a log line is produced. It allows the application to monitor Tor's + * bootstrap progress and connection status. + */ +fun interface ArtiLogListener { + /** + * Called when Arti produces a log line. + * + * @param logLine The log message from Arti, or null if no message + */ + fun onLogLine(logLine: String?) +} diff --git a/app/src/main/java/info/guardianproject/arti/ArtiProxy.kt b/app/src/main/java/info/guardianproject/arti/ArtiProxy.kt new file mode 100644 index 00000000..a81350ad --- /dev/null +++ b/app/src/main/java/info/guardianproject/arti/ArtiProxy.kt @@ -0,0 +1,183 @@ +package info.guardianproject.arti + +import android.app.Application +import android.util.Log +import org.torproject.arti.ArtiNative +import java.io.File + +/** + * Arti Tor proxy implementation compatible with Guardian Project API. + * + * This class provides a wrapper around the custom-built Arti native library, + * maintaining API compatibility with Guardian Project's arti-mobile-ex library. + * + * Usage: + * ``` + * val proxy = ArtiProxy.Builder(application) + * .setSocksPort(9050) + * .setDnsPort(9051) + * .setLogListener { logLine -> Log.i("Arti", logLine ?: "") } + * .build() + * + * proxy.start() + * // ... use proxy ... + * proxy.stop() + * ``` + */ +class ArtiProxy private constructor( + private val application: Application, + private val socksPort: Int, + private val dnsPort: Int, + private val logListener: ArtiLogListener? +) { + companion object { + private const val TAG = "ArtiProxy" + } + + @Volatile + private var isRunning = false + + /** + * Start the Arti Tor proxy. + * + * This method: + * 1. Registers log callback + * 2. Initializes Arti runtime with data directory + * 3. Starts SOCKS proxy on configured port + * + * @throws ArtiException if initialization or startup fails + */ + fun start() { + if (isRunning) { + Log.w(TAG, "Arti already running") + return + } + + try { + logListener?.let { listener -> + Log.d(TAG, "Registering log callback") + ArtiNative.setLogCallback(listener) + } + + val dataDir = getDataDirectory() + Log.i(TAG, "Initializing Arti with data directory: $dataDir") + + val initResult = ArtiNative.initialize(dataDir.absolutePath) + if (initResult != 0) { + throw ArtiException("Failed to initialize Arti: error code $initResult") + } + + Log.i(TAG, "Starting SOCKS proxy on port $socksPort (DNS port: $dnsPort)") + val startResult = ArtiNative.startSocksProxy(socksPort) + when (startResult) { + 0 -> { + isRunning = true + Log.i(TAG, "Arti started successfully") + } + -1 -> throw ArtiException("Arti client not initialized") + -2 -> throw ArtiException("Tokio runtime not initialized") + -3 -> throw ArtiException("Failed to bind SOCKS proxy to port $socksPort (port already in use)") + else -> throw ArtiException("Failed to start SOCKS proxy: error code $startResult") + } + + } catch (e: Exception) { + Log.e(TAG, "Failed to start Arti", e) + if (e is ArtiException) { + throw e + } else { + throw ArtiException("Failed to start Arti: ${e.message}", e) + } + } + } + + /** + * Stop the Arti Tor proxy. + * + * This method gracefully shuts down the Tor client and SOCKS proxy. + */ + fun stop() { + if (!isRunning) { + Log.w(TAG, "Arti not running") + return + } + + try { + Log.i(TAG, "Stopping Arti...") + val stopResult = ArtiNative.stop() + if (stopResult != 0) { + Log.w(TAG, "Stop returned error code: $stopResult") + } + + isRunning = false + Log.i(TAG, "Arti stopped successfully") + + } catch (e: Exception) { + Log.e(TAG, "Error stopping Arti", e) + } + } + + /** + * Get or create Arti data directory. + * + * Directory structure: + * - {app_data}/arti/cache - Tor directory cache + * - {app_data}/arti/state - Tor persistent state + */ + private fun getDataDirectory(): File { + val artiDir = File(application.filesDir, "arti") + if (!artiDir.exists()) { + artiDir.mkdirs() + } + + File(artiDir, "cache").apply { if (!exists()) mkdirs() } + File(artiDir, "state").apply { if (!exists()) mkdirs() } + + return artiDir + } + + /** + * Builder for ArtiProxy instances. + * + * Provides fluent API for configuring proxy settings before creation. + */ + class Builder(private val application: Application) { + private var socksPort: Int = 9050 + private var dnsPort: Int = 9051 + private var logListener: ArtiLogListener? = null + + /** + * Set SOCKS proxy port. + * @param port Port number (default: 9050) + * @return this Builder for chaining + */ + fun setSocksPort(port: Int) = apply { + this.socksPort = port + } + + /** + * Set DNS port. + * @param port Port number (default: 9051) + * @return this Builder for chaining + */ + fun setDnsPort(port: Int) = apply { + this.dnsPort = port + } + + /** + * Set log listener for Arti log messages. + * @param listener Callback for log lines + * @return this Builder for chaining + */ + fun setLogListener(listener: ArtiLogListener) = apply { + this.logListener = listener + } + + /** + * Build and return the configured ArtiProxy instance. + * @return Configured ArtiProxy (not yet started) + */ + fun build(): ArtiProxy { + return ArtiProxy(application, socksPort, dnsPort, logListener) + } + } +} diff --git a/app/src/main/java/org/torproject/arti/ArtiNative.kt b/app/src/main/java/org/torproject/arti/ArtiNative.kt new file mode 100644 index 00000000..13eca6db --- /dev/null +++ b/app/src/main/java/org/torproject/arti/ArtiNative.kt @@ -0,0 +1,54 @@ +package org.torproject.arti + +import info.guardianproject.arti.ArtiLogListener + +/** + * JNI wrapper for custom-built Arti (Tor implementation in Rust) + * + * This class provides native bindings to libarti_android.so compiled from + * the latest Arti source with rustls (no OpenSSL dependency). + * + * Features: + * - Latest Arti v1.7.0 code + * - 16KB page size support (Google Play Nov 2025 ready) + * - Onion service client support + * - Pure Rust TLS (rustls) + */ +object ArtiNative { + + init { + System.loadLibrary("arti_android") + } + + /** + * Get Arti version string + * @return Version string from native library + */ + external fun getVersion(): String + + /** + * Set log callback for Arti logs + * @param callback Callback object with onLogLine(String?) method + */ + external fun setLogCallback(callback: ArtiLogListener) + + /** + * Initialize Arti runtime + * @param dataDir Directory for Arti state/cache + * @return 0 on success, error code otherwise + */ + external fun initialize(dataDir: String): Int + + /** + * Start SOCKS proxy on specified port + * @param port Port number for SOCKS proxy (e.g., 9050) + * @return 0 on success, error code otherwise + */ + external fun startSocksProxy(port: Int): Int + + /** + * Stop Arti and cleanup + * @return 0 on success, error code otherwise + */ + external fun stop(): Int +} \ No newline at end of file diff --git a/app/src/main/jniLibs/arm64-v8a/libarti_android.so b/app/src/main/jniLibs/arm64-v8a/libarti_android.so new file mode 100755 index 00000000..ba4f614b Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libarti_android.so differ diff --git a/app/src/main/jniLibs/x86_64/libarti_android.so b/app/src/main/jniLibs/x86_64/libarti_android.so new file mode 100755 index 00000000..9a1e27b6 Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libarti_android.so differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc4903f6..4a985c6c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -61,6 +61,11 @@ and %1$d more locations and %1$d more + + Mesh Background Service + Keeps the Bluetooth mesh running in the background + Mesh running — %1$d users connected + Add to favorites Remove from favorites @@ -152,6 +157,9 @@ Remove bookmark Teleport Selected + Quit bitchat + run in background + keep mesh active when app is closed (foreground service) Leave channel Reachable via Nostr Offline favorite @@ -378,8 +386,9 @@ Password Join Cancel + Tor not available in this build - + and %d more and %d more 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 6afdf6d6..f1b33d32 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -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() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6dda224..8e0790a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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 = [ diff --git a/tools/arti-build/ARTI_VERSION b/tools/arti-build/ARTI_VERSION new file mode 100644 index 00000000..c68d1ff0 --- /dev/null +++ b/tools/arti-build/ARTI_VERSION @@ -0,0 +1 @@ +arti-v1.7.0 diff --git a/tools/arti-build/Cargo.toml b/tools/arti-build/Cargo.toml new file mode 100644 index 00000000..05664f90 --- /dev/null +++ b/tools/arti-build/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "arti-android-wrapper" +version = "1.7.0" +edition = "2021" + +[workspace] +# Empty workspace table to exclude from parent workspace + +[lib] +crate-type = ["cdylib"] +name = "arti_android" + +[dependencies] +arti-client = { path = "../crates/arti-client", default-features = false, features = ["tokio", "rustls", "compression", "bridge-client", "onion-service-client", "static-sqlite"] } +tor-rtcompat = { path = "../crates/tor-rtcompat", features = ["tokio", "rustls"] } +jni = "0.21" +tokio = { version = "1", features = ["full"] } +anyhow = "1.0" + +[profile.release] +opt-level = "z" # Optimize for size +lto = true # Link-time optimization +codegen-units = 1 # Better optimization +strip = true # Strip symbols +panic = "abort" # Smaller panic handler \ No newline at end of file diff --git a/tools/arti-build/README.md b/tools/arti-build/README.md new file mode 100644 index 00000000..f547b773 --- /dev/null +++ b/tools/arti-build/README.md @@ -0,0 +1,223 @@ +# Arti Android Build Tools + +This directory contains the build scripts and source files for compiling the custom Arti (Tor in Rust) library for Android. + +## Overview + +bitchat-android uses a custom-built Arti library instead of Guardian Project's outdated `arti-mobile-ex` AAR. This provides: + +- **Smaller APK size**: ~11MB total vs ~140MB with Guardian Project AAR (28x reduction) +- **Latest Arti version**: Currently v1.7.0 with pure Rust TLS (rustls) +- **16KB page size support**: Required for Google Play (Nov 2025) +- **Full transparency**: Build from official Arti source + our JNI wrapper + +## Quick Start + +The pre-built `.so` files are committed to the repo, so you don't need to build unless you want to: + +1. **Verify the binaries** match the source +2. **Update to a new Arti version** +3. **Modify the JNI wrapper** + +## Directory Structure + +``` +tools/arti-build/ +├── README.md # This file +├── build-arti.sh # Main build script (clones Arti, builds .so files) +├── ARTI_VERSION # Pinned Arti version tag (e.g., arti-v1.7.0) +├── Cargo.toml # Rust package configuration +├── src/ +│ └── lib.rs # JNI wrapper (Rust -> Kotlin/Java bridge) +└── .arti-source/ # [GITIGNORED] Cloned official Arti repo + +app/src/main/jniLibs/ # [COMMITTED] Pre-built native libraries +├── arm64-v8a/ +│ └── libarti_android.so (~5.3MB) +└── x86_64/ + └── libarti_android.so (~6.2MB) +``` + +## Rebuilding from Source + +### Prerequisites + +1. **Rust toolchain** with Android targets: + ```bash + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + rustup target add aarch64-linux-android x86_64-linux-android + ``` + +2. **cargo-ndk** for Android cross-compilation: + ```bash + cargo install cargo-ndk + ``` + +3. **Android NDK 25+** (for 16KB page size support): + ```bash + # Via Android Studio SDK Manager, or: + $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager "ndk;27.0.12077973" + + # Set environment variable + export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/27.0.12077973" + ``` + +### Build Commands + +```bash +cd tools/arti-build + +# Build both architectures (arm64 + x86_64 for emulator) +./build-arti.sh + +# Build ARM64 only (smaller, for production releases) +./build-arti.sh --release + +# Clean rebuild (re-clone Arti source) +./build-arti.sh --clean +``` + +The script will: +1. Clone official Arti from https://gitlab.torproject.org/tpo/core/arti +2. Checkout the version specified in `ARTI_VERSION` +3. Copy our JNI wrapper into the cloned repo +4. Build with `cargo ndk` +5. Copy `.so` files to `app/src/main/jniLibs/` + +### Verification + +After building, verify the libraries: + +```bash +# Check file sizes +ls -lh ../../app/src/main/jniLibs/*/libarti_android.so + +# Verify JNI symbols are exported +nm -gU ../../app/src/main/jniLibs/arm64-v8a/libarti_android.so | grep Java_org_torproject + +# Verify 16KB page alignment +readelf -l ../../app/src/main/jniLibs/arm64-v8a/libarti_android.so | grep LOAD +# Look for: Align 0x4000 (16KB) +``` + +## Updating Arti Version + +1. **Check available versions**: + ```bash + git ls-remote --tags https://gitlab.torproject.org/tpo/core/arti.git | grep arti-v + ``` + +2. **Update the version file**: + ```bash + echo "arti-v1.8.0" > ARTI_VERSION + ``` + +3. **Rebuild from scratch**: + ```bash + ./build-arti.sh --clean + ``` + +4. **Test the build**: + ```bash + cd ../.. + ./gradlew clean assembleDebug + ./gradlew installDebug + # Enable Tor in app and verify it works + ``` + +5. **Commit the new libraries**: + ```bash + git add app/src/main/jniLibs/ tools/arti-build/ARTI_VERSION + git commit -m "chore: update Arti to v1.8.0" + ``` + +## JNI Wrapper Architecture + +The `src/lib.rs` file implements a JNI bridge between Kotlin and Rust: + +``` +Kotlin (ArtiNative.kt) + ↓ JNI +Rust (lib.rs) + ↓ +Arti Client (TorClient) + ↓ +SOCKS5 Proxy (localhost:9060) +``` + +**Exported JNI Functions**: +- `getVersion()` - Returns Arti version string +- `setLogCallback(callback)` - Registers log listener for bootstrap progress +- `initialize(dataDir)` - Creates Tokio runtime and TorClient +- `startSocksProxy(port)` - Starts SOCKS5 proxy on specified port +- `stop()` - Stops SOCKS proxy (TorClient is reused) + +**Key Design Decisions**: +- Global `TorClient` persists across stop/start cycles (fixes Nov 2024 toggle bug) +- Tokio runtime created once and never destroyed +- Log messages bridged to Java via `GlobalRef` callback + +## Feature Configuration + +Edit `Cargo.toml` to customize Arti features: + +```toml +[dependencies] +arti-client = { + path = "../crates/arti-client", + default-features = false, + features = [ + "tokio", # Required: async runtime + "rustls", # Required: pure Rust TLS (no OpenSSL) + "compression", # Optional: directory compression + "bridge-client", # Optional: Tor bridge support + "onion-service-client", # Optional: .onion site support + "static-sqlite" # Required: bundled SQLite + ] +} +``` + +## Size Comparison + +| Configuration | arm64-v8a | x86_64 | Total | APK Size | +|---------------|-----------|--------|-------|----------| +| Guardian Project AAR | - | - | ~140 MB | ~150 MB | +| Custom (both arch) | 5.3 MB | 6.2 MB | 11.5 MB | ~15 MB | +| Custom (ARM-only) | 5.3 MB | - | 5.3 MB | ~10 MB | + +**28x size reduction** vs Guardian Project implementation. + +## Troubleshooting + +### "cargo-ndk not found" +```bash +cargo install cargo-ndk +``` + +### "Android NDK not found" +```bash +export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/27.0.12077973" +``` + +### "Rust target not installed" +```bash +rustup target add aarch64-linux-android x86_64-linux-android +``` + +### "Version not found" +Check available versions: +```bash +git ls-remote --tags https://gitlab.torproject.org/tpo/core/arti.git | grep arti-v | tail -10 +``` + +### Library too large +1. Ensure building with `--release` flag +2. Verify `strip = true` in `Cargo.toml` `[profile.release]` +3. Consider removing optional features + +## References + +- [Arti Documentation](https://gitlab.torproject.org/tpo/core/arti/-/blob/main/doc/README.md) +- [cargo-ndk](https://github.com/bbqsrc/cargo-ndk) +- [Android NDK Guide](https://developer.android.com/ndk/guides) +- [Google Play 16KB Page Size](https://developer.android.com/guide/practices/page-sizes) diff --git a/tools/arti-build/build-arti.sh b/tools/arti-build/build-arti.sh new file mode 100755 index 00000000..0a592be5 --- /dev/null +++ b/tools/arti-build/build-arti.sh @@ -0,0 +1,570 @@ +#!/usr/bin/env bash +# +# Rebuild Arti native libraries from official source +# +# This script clones the official Arti repository, applies our custom JNI wrapper, +# and builds the native libraries for Android. Use this to: +# - Verify the pre-built .so files match the source +# - Update to a new Arti version +# - Debug or modify the wrapper code +# +# Requirements: +# - Bash 4+ (macOS default bash is 3.2; install via Homebrew: brew install bash) +# - Rust toolchain with Android targets: +# rustup target add aarch64-linux-android x86_64-linux-android +# - cargo-ndk: cargo install cargo-ndk +# - Android NDK 25+ (for 16KB page size support) +# +# Usage: +# ./build-arti.sh # Build both architectures (debug/emulator) +# ./build-arti.sh --release # Build ARM64 only (production) +# ./build-arti.sh --clean # Remove cloned Arti repo and rebuild + +set -euo pipefail + +# ============================================================================== +# Configuration +# ============================================================================== + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Script and project directories +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +ARTI_SOURCE_DIR="$SCRIPT_DIR/.arti-source" +JNILIBS_DIR="$PROJECT_ROOT/app/src/main/jniLibs" + + +detect_default_ndk_home() { + local candidates=( + "$HOME/Library/Android/sdk/ndk/27.0.12077973" + "$HOME/Library/Android/sdk/ndk" + "$HOME/Library/Android/sdk/ndk-bundle" + "$HOME/Android/Sdk/ndk/27.0.12077973" + "$HOME/Android/Sdk/ndk" + "$HOME/Android/Sdk/ndk-bundle" + ) + + for candidate in "${candidates[@]}"; do + if [ -d "$candidate" ]; then + echo "$candidate" + return + fi + done + + local base + for base in "$HOME/Library/Android/sdk/ndk" "$HOME/Android/Sdk/ndk"; do + if [ -d "$base" ]; then + local latest + latest="$(find "$base" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | tail -1)" + if [ -n "$latest" ]; then + echo "$latest" + return + fi + fi + done + + echo "" +} + +# Read pinned version +if [ ! -f "$SCRIPT_DIR/ARTI_VERSION" ]; then + echo -e "${RED}Error: ARTI_VERSION file not found${NC}" + exit 1 +fi +VERSION="$(tr -d '[:space:]' < "$SCRIPT_DIR/ARTI_VERSION")" + +# Android NDK path +if [ -z "${ANDROID_NDK_HOME:-}" ]; then + AUTO_NDK_HOME="$(detect_default_ndk_home)" + if [ -n "$AUTO_NDK_HOME" ]; then + ANDROID_NDK_HOME="$AUTO_NDK_HOME" + fi +fi +if [ -z "${ANDROID_NDK_HOME:-}" ]; then + echo -e "${RED}Error: ANDROID_NDK_HOME is not set and automatic detection failed.${NC}" + echo "Set ANDROID_NDK_HOME to your NDK installation (e.g., ~/Android/Sdk/ndk/)." + exit 1 +fi +export ANDROID_NDK_HOME + +# Min SDK version (must match bitchat-android minSdk) +MIN_SDK_VERSION=26 + +# Parse arguments +RELEASE_ONLY=false +CLEAN_BUILD=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --release) + RELEASE_ONLY=true + shift + ;; + --clean) + CLEAN_BUILD=true + shift + ;; + --help|-h) + echo "Usage: $0 [--release] [--clean]" + echo "" + echo "Options:" + echo " --release Build ARM64 only (smaller, for production)" + echo " --clean Remove cached Arti source and rebuild from scratch" + echo "" + exit 0 + ;; + *) + echo -e "${RED}Error: Unknown argument: $1${NC}" + echo "Run: $0 --help" + exit 1 + ;; + esac +done + +# Architectures to build +if [ "$RELEASE_ONLY" = true ]; then + TARGETS=("aarch64-linux-android") +else + TARGETS=("aarch64-linux-android" "x86_64-linux-android") +fi + +# Map Rust targets to Android ABI names +declare -A ABI_MAP=( + ["aarch64-linux-android"]="arm64-v8a" + ["x86_64-linux-android"]="x86_64" +) + +# Toolchain placeholders (set in detect_ndk_host) +NDK_HOST="" +NDK_LLVM_BIN="" +LLVM_STRIP="" +LLVM_NM="" +LLVM_READELF="" + +# ============================================================================== +# Functions +# ============================================================================== + +print_header() { + echo -e "${BLUE}=========================================${NC}" + echo -e "${BLUE}$1${NC}" + echo -e "${BLUE}=========================================${NC}" +} + +print_success() { echo -e "${GREEN}$1${NC}"; } +print_error() { echo -e "${RED}$1${NC}"; } +print_info() { echo -e "${YELLOW}$1${NC}"; } + +detect_ndk_host() { + local uname_s + uname_s="$(uname -s)" + local PREBUILT_DIR="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt" + local HOST_CANDIDATES=() + + case "$uname_s" in + Darwin) + HOST_CANDIDATES=("darwin-arm64" "darwin-x86_64") + ;; + Linux) + HOST_CANDIDATES=("linux-x86_64" "linux-arm64" "linux-aarch64") + ;; + *) + print_error "Unsupported host OS: $uname_s" + exit 1 + ;; + esac + + for candidate in "${HOST_CANDIDATES[@]}"; do + if [ -d "$PREBUILT_DIR/$candidate" ]; then + NDK_HOST="$candidate" + NDK_LLVM_BIN="$PREBUILT_DIR/$candidate/bin" + LLVM_STRIP="$NDK_LLVM_BIN/llvm-strip" + LLVM_NM="$NDK_LLVM_BIN/llvm-nm" + LLVM_READELF="$NDK_LLVM_BIN/llvm-readelf" + return 0 + fi + done + + print_error "No compatible NDK toolchain found under $PREBUILT_DIR" + print_info "Searched for: ${HOST_CANDIDATES[*]}" + exit 1 +} + +check_prerequisites() { + print_header "Checking Prerequisites" + + # Bash version + if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then + print_error "Bash 4+ is required. macOS ships bash 3.2 by default." + print_info "macOS: brew install bash" + print_info "Linux: use your distro package manager (e.g., sudo apt install bash)" + print_info "Then run with the installed bash (e.g., /opt/homebrew/bin/bash ./build-arti.sh)" + exit 1 + fi + print_success "Bash found: $BASH_VERSION" + + # Git + if ! command -v git >/dev/null 2>&1; then + print_error "git is not installed." + exit 1 + fi + print_success "git found: $(git --version)" + + # Rust + if ! command -v rustc >/dev/null 2>&1; then + print_error "Rust is not installed. Install from https://rustup.rs/" + exit 1 + fi + print_success "Rust found: $(rustc --version)" + + # rustup + if ! command -v rustup >/dev/null 2>&1; then + print_error "rustup is required (for managing targets). Install from https://rustup.rs/" + exit 1 + fi + print_success "rustup found: $(rustup --version | head -1)" + + # cargo-ndk + if ! command -v cargo-ndk >/dev/null 2>&1; then + print_error "cargo-ndk is not installed. Run: cargo install cargo-ndk" + exit 1 + fi + print_success "cargo-ndk found: $(cargo-ndk --version 2>/dev/null || echo 'installed')" + + # NDK + if [ ! -d "$ANDROID_NDK_HOME" ]; then + print_error "Android NDK not found at: $ANDROID_NDK_HOME" + print_info "Set ANDROID_NDK_HOME environment variable to your NDK location" + exit 1 + fi + print_success "Android NDK found: $ANDROID_NDK_HOME" + + # NDK version (should be 25+) + NDK_VERSION="$(basename "$ANDROID_NDK_HOME" | cut -d'.' -f1)" + if ! [[ "$NDK_VERSION" =~ ^[0-9]+$ ]]; then + print_error "Could not parse NDK version from ANDROID_NDK_HOME: $ANDROID_NDK_HOME" + exit 1 + fi + if [ "$NDK_VERSION" -lt 25 ]; then + print_error "NDK version $NDK_VERSION is too old. NDK 25+ required for 16KB page size support" + exit 1 + fi + print_success "NDK version: $NDK_VERSION (supports 16KB page size)" + + detect_ndk_host + if [ ! -d "$NDK_LLVM_BIN" ]; then + print_error "NDK LLVM toolchain bin directory not found: $NDK_LLVM_BIN" + exit 1 + fi + print_success "NDK host tag: $NDK_HOST" + + if [ ! -x "$LLVM_STRIP" ]; then + print_info "llvm-strip not found at: $LLVM_STRIP (stripping will be skipped)" + else + print_success "llvm-strip found" + fi + + if [ ! -x "$LLVM_NM" ] && ! command -v nm >/dev/null 2>&1; then + print_error "Neither llvm-nm nor nm found. Cannot verify JNI symbols." + exit 1 + fi + if [ -x "$LLVM_NM" ]; then + print_success "llvm-nm found" + else + print_info "llvm-nm not found, will fall back to system nm" + fi + + if [ ! -x "$LLVM_READELF" ] && ! command -v readelf >/dev/null 2>&1; then + print_info "Neither llvm-readelf nor readelf found. Alignment verification may be skipped." + else + print_success "readelf capability available" + fi + # Android targets + for TARGET in "${TARGETS[@]}"; do + if ! rustup target list --installed | grep -qx "$TARGET"; then + print_error "Rust target $TARGET not installed" + print_info "Run: rustup target add $TARGET" + exit 1 + fi + done + print_success "All Rust Android targets installed" + + echo "" +} + +clone_or_update_arti() { + print_header "Setting up Arti Source (version: $VERSION)" + + if [ "$CLEAN_BUILD" = true ] && [ -d "$ARTI_SOURCE_DIR" ]; then + print_info "Cleaning existing Arti source..." + rm -rf "$ARTI_SOURCE_DIR" + fi + + if [ ! -d "$ARTI_SOURCE_DIR" ]; then + print_info "Cloning official Arti repository..." + git clone https://gitlab.torproject.org/tpo/core/arti.git "$ARTI_SOURCE_DIR" + else + print_info "Using cached Arti source at $ARTI_SOURCE_DIR" + fi + + cd "$ARTI_SOURCE_DIR" + + print_info "Fetching tags..." + git fetch --tags --quiet + + print_info "Checking out version: $VERSION" + git checkout "$VERSION" --quiet 2>/dev/null || { + print_error "Version $VERSION not found. Available versions:" + git tag | grep "^arti-v" | tail -10 + exit 1 + } + + # Ensure clean working tree to avoid cached modifications influencing builds + print_info "Resetting repository state (hard) and cleaning untracked files..." + git reset --hard --quiet + git clean -ffdqx --quiet + + print_success "Arti source ready at version $VERSION" + echo "" +} + +setup_wrapper() { + print_header "Setting up JNI Wrapper" + + WRAPPER_DIR="$ARTI_SOURCE_DIR/arti-android-wrapper" + + # Recreate wrapper directory to avoid stale files + rm -rf "$WRAPPER_DIR" + mkdir -p "$WRAPPER_DIR/src" + + cp "$SCRIPT_DIR/src/lib.rs" "$WRAPPER_DIR/src/" + cp "$SCRIPT_DIR/Cargo.toml" "$WRAPPER_DIR/" + + print_success "Wrapper files copied to $WRAPPER_DIR" + echo "" +} + +build_for_target() { + local TARGET="$1" + local ABI="${ABI_MAP[$TARGET]}" + local OUTPUT_PATH="$JNILIBS_DIR/$ABI" + + print_header "Building for $ABI ($TARGET)" + + mkdir -p "$OUTPUT_PATH" + + print_info "Building Arti Android wrapper..." + cargo ndk \ + -t "$TARGET" \ + --platform "$MIN_SDK_VERSION" \ + -o "$OUTPUT_PATH" \ + build --release \ + --locked \ + --manifest-path "$ARTI_SOURCE_DIR/arti-android-wrapper/Cargo.toml" + + local LIB_NAME="libarti_android.so" + local NESTED_PATH="$OUTPUT_PATH/$ABI/$LIB_NAME" + + if [ -f "$NESTED_PATH" ]; then + mv "$NESTED_PATH" "$OUTPUT_PATH/$LIB_NAME" + rmdir "$OUTPUT_PATH/$ABI" 2>/dev/null || true + fi + + if [ -f "$OUTPUT_PATH/$LIB_NAME" ]; then + print_success "Built: $OUTPUT_PATH/$LIB_NAME" + + # Strip debug symbols safely + print_info "Stripping debug symbols..." + if [ -x "$LLVM_STRIP" ]; then + "$LLVM_STRIP" --strip-debug "$OUTPUT_PATH/$LIB_NAME" 2>/dev/null || true + print_success "Stripped debug symbols" + else + print_info "Skipping strip (llvm-strip not available)" + fi + + local SIZE + SIZE="$(du -h "$OUTPUT_PATH/$LIB_NAME" | cut -f1)" + print_success "Final size: $SIZE" + + # Verify 16KB page size alignment (best-effort) + print_info "Verifying 16KB page alignment..." + local READELF_TOOL="" + if [ -x "$LLVM_READELF" ]; then + READELF_TOOL="$LLVM_READELF" + elif command -v readelf >/dev/null 2>&1; then + READELF_TOOL="$(command -v readelf)" + fi + + if [ -n "$READELF_TOOL" ]; then + local ALIGNMENT + ALIGNMENT="$("$READELF_TOOL" -l "$OUTPUT_PATH/$LIB_NAME" 2>/dev/null | grep "LOAD" | head -1 | awk '{print $NF}' || echo "unknown")" + if [ "$ALIGNMENT" = "0x4000" ] || [ "$ALIGNMENT" = "16384" ]; then + print_success "16KB page alignment verified: $ALIGNMENT" + else + print_info "Page alignment: $ALIGNMENT (NDK handles 16KB at link time)" + fi + else + print_info "Skipping alignment check (readelf not available)" + fi + else + print_error "Build failed: $LIB_NAME not found" + return 1 + fi + + echo "" +} + +verify_jni_symbols_for_lib() { + local LIB_PATH="$1" + + if [ ! -f "$LIB_PATH" ]; then + print_error "Library not found: $LIB_PATH" + return 1 + fi + + local EXPECTED_SYMBOLS=( + "Java_org_torproject_arti_ArtiNative_getVersion" + "Java_org_torproject_arti_ArtiNative_setLogCallback" + "Java_org_torproject_arti_ArtiNative_initialize" + "Java_org_torproject_arti_ArtiNative_startSocksProxy" + "Java_org_torproject_arti_ArtiNative_stop" + ) + + local ALL_FOUND=true + for SYMBOL in "${EXPECTED_SYMBOLS[@]}"; do + local FOUND=false + + if [ -x "$LLVM_NM" ]; then + if "$LLVM_NM" -D --defined-only "$LIB_PATH" 2>/dev/null | grep -q "$SYMBOL"; then + FOUND=true + fi + elif command -v nm >/dev/null 2>&1; then + # Fallback (may be unreliable for ELF on macOS) + if nm -g "$LIB_PATH" 2>/dev/null | grep -q "$SYMBOL"; then + FOUND=true + fi + fi + + if [ "$FOUND" = true ]; then + print_success " Found: $SYMBOL" + else + print_error " Missing: $SYMBOL" + ALL_FOUND=false + fi + done + + if [ "$ALL_FOUND" = true ]; then + print_success "All JNI symbols verified for: $LIB_PATH" + else + print_error "Some JNI symbols are missing for: $LIB_PATH" + return 1 + fi + + return 0 +} + +verify_jni_symbols() { + print_header "Verifying JNI Symbols" + print_info "Checking exported JNI symbols..." + + local FAILED=false + + for TARGET in "${TARGETS[@]}"; do + local ABI="${ABI_MAP[$TARGET]}" + local LIB_PATH="$JNILIBS_DIR/$ABI/libarti_android.so" + print_info "Verifying $ABI: $LIB_PATH" + if ! verify_jni_symbols_for_lib "$LIB_PATH"; then + FAILED=true + fi + done + + if [ "$FAILED" = true ]; then + return 1 + fi + + echo "" +} + +show_summary() { + print_header "Build Complete!" + + echo -e "${GREEN}Built libraries:${NC}" + for TARGET in "${TARGETS[@]}"; do + local ABI="${ABI_MAP[$TARGET]}" + local LIB_PATH="$JNILIBS_DIR/$ABI/libarti_android.so" + if [ -f "$LIB_PATH" ]; then + local SIZE + SIZE="$(du -h "$LIB_PATH" | cut -f1)" + echo -e " ${GREEN}$ABI:${NC} $SIZE" + fi + done + + echo "" + echo -e "${GREEN}Arti version:${NC} $VERSION" + echo -e "${GREEN}Source:${NC} https://gitlab.torproject.org/tpo/core/arti" + echo "" + echo -e "${GREEN}Next steps:${NC}" + echo " 1. Test the build: ./gradlew assembleDebug" + echo " 2. Commit the .so files: git add app/src/main/jniLibs/" + echo "" + echo -e "${GREEN}To update Arti version:${NC}" + echo " 1. Edit ARTI_VERSION with new version tag (e.g., arti-v1.8.0)" + echo " 2. Run: ./build-arti.sh --clean" + echo "" +} + +# ============================================================================== +# Main +# ============================================================================== + +main() { + print_header "Arti Android Build Script" + echo -e "${BLUE}Building Arti for Android with 16KB page size support${NC}" + echo -e "${BLUE}Version: $VERSION${NC}" + echo -e "${BLUE}Architectures: ${TARGETS[*]}${NC}" + echo "" + + check_prerequisites + clone_or_update_arti + setup_wrapper + ensure_wrapper_lockfile + + for TARGET in "${TARGETS[@]}"; do + build_for_target "$TARGET" + done + + verify_jni_symbols + show_summary +} + +ensure_wrapper_lockfile() { + print_header "Ensuring wrapper Cargo.lock exists" + + local WRAPPER_DIR="$ARTI_SOURCE_DIR/arti-android-wrapper" + local LOCKFILE="$WRAPPER_DIR/Cargo.lock" + + if [ -f "$LOCKFILE" ]; then + print_success "Cargo.lock already exists" + echo "" + return 0 + fi + + print_info "Cargo.lock missing; generating it once (network access may be required)..." + (cd "$WRAPPER_DIR" && cargo generate-lockfile) + + if [ ! -f "$LOCKFILE" ]; then + print_error "Failed to generate Cargo.lock at $LOCKFILE" + return 1 + fi + + print_success "Generated Cargo.lock" + echo "" +} + +main diff --git a/tools/arti-build/src/lib.rs b/tools/arti-build/src/lib.rs new file mode 100644 index 00000000..941b1386 --- /dev/null +++ b/tools/arti-build/src/lib.rs @@ -0,0 +1,493 @@ +use jni::JNIEnv; +use jni::objects::{JClass, JString, JObject, GlobalRef}; +use jni::sys::{jint, jstring}; +use jni::JavaVM; + +use arti_client::TorClient; +use arti_client::config::TorClientConfigBuilder; +use tor_rtcompat::PreferredRuntime; + +use std::sync::{Arc, Mutex, Once}; +use std::path::PathBuf; +use anyhow::Result; + +// ============================================================================ +// Global State +// ============================================================================ + +/// Global Arti client instance +static ARTI_CLIENT: Mutex>>> = Mutex::new(None); + +/// Global Tokio runtime (must persist for Arti to work) +static TOKIO_RUNTIME: Mutex> = Mutex::new(None); + +/// Global JavaVM reference (cached on first JNI call) +static JAVA_VM: Mutex> = Mutex::new(None); + +/// Global log callback reference +static LOG_CALLBACK: Mutex> = Mutex::new(None); + +/// Handle to SOCKS server task (for graceful shutdown) +static SOCKS_TASK: Mutex>> = Mutex::new(None); + +/// Initialization flag +static INIT_ONCE: Once = Once::new(); + +// ============================================================================ +// Logging Integration +// ============================================================================ + +/// Send log message to Java callback +fn send_log_to_java(message: String) { + let vm_opt = JAVA_VM.lock().unwrap(); + let callback_opt = LOG_CALLBACK.lock().unwrap(); + + if let (Some(vm), Some(callback)) = (vm_opt.as_ref(), callback_opt.as_ref()) { + if let Ok(mut env) = vm.attach_current_thread() { + if let Ok(jmessage) = env.new_string(&message) { + let _ = env.call_method( + callback.as_obj(), + "onLogLine", + "(Ljava/lang/String;)V", + &[(&jmessage).into()] + ); + } + } + } +} + +/// Macro for logging to both Android logcat and Java callback +macro_rules! log_info { + ($($arg:tt)*) => {{ + let msg = format!($($arg)*); + android_logger::log(&format!("Arti: {}", msg)); + send_log_to_java(msg); + }}; +} + +macro_rules! log_error { + ($($arg:tt)*) => {{ + let msg = format!("ERROR: {}", format!($($arg)*)); + android_logger::log(&format!("Arti: {}", msg)); + send_log_to_java(msg); + }}; +} + +// ============================================================================ +// JNI Functions +// ============================================================================ + +/// Get Arti version string +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_getVersion( + env: JNIEnv, + _class: JClass, +) -> jstring { + // Cache JavaVM on first call + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + let version = format!("Arti {} (custom build with rustls)", env!("CARGO_PKG_VERSION")); + let output = env.new_string(version).expect("Couldn't create java string!"); + output.into_raw() +} + +/// Set log callback for Arti logs +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_setLogCallback( + env: JNIEnv, + _class: JClass, + callback: JObject, +) { + // Cache JavaVM if not already cached + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + // Store global reference to callback + if let Ok(global_ref) = env.new_global_ref(callback) { + *LOG_CALLBACK.lock().unwrap() = Some(global_ref); + log_info!("Log callback registered"); + } +} + +/// Initialize Arti runtime +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_initialize( + mut env: JNIEnv, + _class: JClass, + data_dir: JString, +) -> jint { + // Cache JavaVM if not already cached + if JAVA_VM.lock().unwrap().is_none() { + if let Ok(vm) = env.get_java_vm() { + *JAVA_VM.lock().unwrap() = Some(vm); + } + } + + let data_dir_str: String = match env.get_string(&data_dir) { + Ok(s) => s.into(), + Err(e) => { + log_error!("Failed to convert data_dir: {:?}", e); + return -1; + } + }; + + log_info!("AMEx: state changed to Initialized"); + log_info!("Initializing Arti with data directory: {}", data_dir_str); + + // Initialize Tokio runtime (once) + INIT_ONCE.call_once(|| { + match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(rt) => { + log_info!("Tokio runtime created successfully"); + *TOKIO_RUNTIME.lock().unwrap() = Some(rt); + } + Err(e) => { + log_error!("Failed to create Tokio runtime: {:?}", e); + } + } + }); + + // Check if runtime exists + let runtime_guard = TOKIO_RUNTIME.lock().unwrap(); + let runtime = match runtime_guard.as_ref() { + Some(rt) => rt, + None => { + log_error!("Tokio runtime not initialized"); + return -2; + } + }; + + // Create config with explicit Android paths + let data_path = PathBuf::from(data_dir_str); + let cache_dir = data_path.join("cache"); + let state_dir = data_path.join("state"); + + // Create directories if they don't exist + std::fs::create_dir_all(&cache_dir).ok(); + std::fs::create_dir_all(&state_dir).ok(); + + let result: Result<()> = runtime.block_on(async { + log_info!("Creating Arti client..."); + log_info!("Cache dir: {:?}", cache_dir); + log_info!("State dir: {:?}", state_dir); + + // Create config with Android-specific directories + let config = TorClientConfigBuilder::from_directories(state_dir, cache_dir) + .build()?; + + // Create client with Android-specific config + let client = TorClient::create_bootstrapped(config).await?; + + log_info!("Arti client created successfully"); + + // Store client globally + *ARTI_CLIENT.lock().unwrap() = Some(Arc::new(client)); + + Ok(()) + }); + + match result { + Ok(_) => { + log_info!("Arti initialized successfully"); + 0 + } + Err(e) => { + log_error!("Failed to initialize Arti: {:?}", e); + -3 + } + } +} + +/// Start SOCKS proxy on specified port +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_startSocksProxy( + _env: JNIEnv, + _class: JClass, + port: jint, +) -> jint { + log_info!("AMEx: state changed to Starting"); + log_info!("Starting SOCKS proxy on port {}", port); + + // Stop any existing SOCKS server first + if let Some(handle) = SOCKS_TASK.lock().unwrap().take() { + log_info!("Aborting previous SOCKS server task"); + handle.abort(); + } + + let client_guard = ARTI_CLIENT.lock().unwrap(); + let client = match client_guard.as_ref() { + Some(c) => Arc::clone(c), + None => { + log_error!("Arti client not initialized - call initialize() first"); + return -1; + } + }; + drop(client_guard); + + let runtime_guard = TOKIO_RUNTIME.lock().unwrap(); + let runtime = match runtime_guard.as_ref() { + Some(rt) => rt, + None => { + log_error!("Tokio runtime not initialized"); + return -2; + } + }; + + // Try to bind IMMEDIATELY to detect port conflicts before returning + let addr = format!("127.0.0.1:{}", port); + + // Use block_on to synchronously attempt binding + let bind_result = runtime.block_on(async { + tokio::net::TcpListener::bind(&addr).await + }); + + let listener = match bind_result { + Ok(l) => { + log_info!("SOCKS proxy bound to {}", addr); + l + } + Err(e) => { + log_error!("Failed to bind SOCKS proxy to {}: {:?}", addr, e); + return -3; + } + }; + + // Now spawn the background task with the already-bound listener + let handle = runtime.spawn(async move { + log_info!("SOCKS proxy listening on {}", addr); + log_info!("Sufficiently bootstrapped; system SOCKS now functional"); + + // Signal bootstrap completion to bitchat-android (expected by ArtiTorManager) + // This sets bootstrapPercent to 100% and stops inactivity restarts + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + log_info!("We have found that guard [scrubbed] is usable."); + + // Accept connections + loop { + match listener.accept().await { + Ok((stream, peer_addr)) => { + log_info!("SOCKS connection from: {}", peer_addr); + let client_clone = Arc::clone(&client); + + tokio::spawn(async move { + if let Err(e) = handle_socks_connection(stream, client_clone).await { + log_error!("SOCKS connection error: {:?}", e); + } + }); + } + Err(e) => { + log_error!("Failed to accept SOCKS connection: {:?}", e); + break; // Exit loop on error + } + } + } + + log_info!("SOCKS proxy task exiting"); + }); + + // Store handle for cleanup + *SOCKS_TASK.lock().unwrap() = Some(handle); + + log_info!("SOCKS proxy started on port {}", port); + 0 +} + +/// Handle a single SOCKS connection +async fn handle_socks_connection( + mut stream: tokio::net::TcpStream, + client: Arc>, +) -> Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // Simple SOCKS5 handshake + let mut buf = [0u8; 512]; + + // Read version + methods + let n = stream.read(&mut buf).await?; + if n < 2 { + return Err(anyhow::anyhow!("Invalid SOCKS handshake")); + } + + // Send "no auth required" response + stream.write_all(&[0x05, 0x00]).await?; + + // Read request + let n = stream.read(&mut buf).await?; + if n < 10 { + return Err(anyhow::anyhow!("Invalid SOCKS request")); + } + + // Parse SOCKS5 request: VER(1) CMD(1) RSV(1) ATYP(1) DST.ADDR DST.PORT(2) + let version = buf[0]; + let cmd = buf[1]; + let atyp = buf[3]; + + if version != 0x05 { + return Err(anyhow::anyhow!("Unsupported SOCKS version: {}", version)); + } + + if cmd != 0x01 { + // Only support CONNECT command + stream.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Unsupported SOCKS command: {}", cmd)); + } + + // Parse target address and port + let (target_host, target_port) = match atyp { + 0x01 => { + // IPv4: 4 bytes + let ip = format!("{}.{}.{}.{}", buf[4], buf[5], buf[6], buf[7]); + let port = u16::from_be_bytes([buf[8], buf[9]]); + (ip, port) + } + 0x03 => { + // Domain name: length byte + domain + let len = buf[4] as usize; + if n < 5 + len + 2 { + return Err(anyhow::anyhow!("Invalid domain name length")); + } + let domain = String::from_utf8_lossy(&buf[5..5 + len]).to_string(); + let port = u16::from_be_bytes([buf[5 + len], buf[5 + len + 1]]); + (domain, port) + } + 0x04 => { + // IPv6: 16 bytes + 2 bytes port = 22 bytes total + if n < 22 { + stream.write_all(&[0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Truncated IPv6 request")); + } + let ip = format!( + "{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}:{:02x}{:02x}", + buf[4], buf[5], buf[6], buf[7], buf[8], buf[9], buf[10], buf[11], + buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19] + ); + let port = u16::from_be_bytes([buf[20], buf[21]]); + (ip, port) + } + _ => { + stream.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(anyhow::anyhow!("Unsupported address type: {}", atyp)); + } + }; + + log_info!("SOCKS5 CONNECT to {}:{}", target_host, target_port); + + // Establish Tor connection + let tor_stream = match client.connect((target_host.as_str(), target_port)).await { + Ok(s) => s, + Err(e) => { + log_error!("Failed to connect through Tor: {:?}", e); + // Send SOCKS5 error: general failure + stream.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Err(e.into()); + } + }; + + log_info!("Tor connection established to {}:{}", target_host, target_port); + + // Send SOCKS5 success response + stream.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + + // Bidirectional data forwarding + let (mut client_read, mut client_write) = stream.split(); + let (mut tor_read, mut tor_write) = tor_stream.split(); + + let client_to_tor = async { + tokio::io::copy(&mut client_read, &mut tor_write).await + }; + + let tor_to_client = async { + tokio::io::copy(&mut tor_read, &mut client_write).await + }; + + // Run both directions concurrently, exit when either completes + tokio::select! { + result = client_to_tor => { + if let Err(ref e) = result { + log_error!("Client->Tor copy error: {:?}", e); + } + } + result = tor_to_client => { + if let Err(ref e) = result { + log_error!("Tor->Client copy error: {:?}", e); + } + } + }; + + log_info!("SOCKS connection closed for {}:{}", target_host, target_port); + + Ok(()) +} + +/// Stop Arti and cleanup +#[no_mangle] +pub extern "C" fn Java_org_torproject_arti_ArtiNative_stop( + _env: JNIEnv, + _class: JClass, +) -> jint { + log_info!("AMEx: state changed to Stopping"); + log_info!("Stopping Arti..."); + + // Abort SOCKS proxy task (releases the port) + if let Some(handle) = SOCKS_TASK.lock().unwrap().take() { + log_info!("Aborting SOCKS server task"); + handle.abort(); + } + + // Give the abort a moment to complete and release the port + if let Some(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref() { + rt.block_on(async { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + }); + } + + // NOTE: We do NOT clear ARTI_CLIENT here! + // The TorClient can be reused for multiple SOCKS proxy start/stop cycles. + // Only clear it if you want to force full reinitialization. + + // Uncomment this line only if you want to force reinitialization on every start: + // *ARTI_CLIENT.lock().unwrap() = None; + + log_info!("AMEx: state changed to Stopped"); + log_info!("Arti stopped successfully"); + + 0 +} + +// ============================================================================ +// Android Logger (simple implementation) +// ============================================================================ + +mod android_logger { + use std::ffi::CString; + + #[allow(non_camel_case_types)] + type c_int = i32; + + #[allow(non_camel_case_types)] + type c_char = i8; + + extern "C" { + fn __android_log_write(prio: c_int, tag: *const c_char, text: *const c_char) -> c_int; + } + + const ANDROID_LOG_INFO: c_int = 4; + + pub fn log(message: &str) { + unsafe { + let tag = CString::new("ArtiNative").unwrap(); + let text = CString::new(message).unwrap(); + __android_log_write(ANDROID_LOG_INFO, tag.as_ptr() as *const c_char, text.as_ptr() as *const c_char); + } + } +}