Merge branch 'main' into gossip-routing-2

This commit is contained in:
callebtc
2025-10-28 09:00:01 +01:00
111 changed files with 12637 additions and 799 deletions
@@ -19,6 +19,9 @@ class BitchatApplication : Application() {
// Initialize relay directory (loads assets/nostr_relays.csv)
RelayDirectory.initialize(this)
// Initialize LocationNotesManager dependencies early so sheet subscriptions can start immediately
try { com.bitchat.android.nostr.LocationNotesInitializer.initialize(this) } catch (_: Exception) { }
// Initialize favorites persistence early so MessageRouter/NostrTransport can use it on startup
try {
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this)
@@ -3,7 +3,6 @@ package com.bitchat.android
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -15,7 +14,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.repeatOnLifecycle
@@ -39,13 +37,14 @@ import com.bitchat.android.onboarding.PermissionExplanationScreen
import com.bitchat.android.onboarding.PermissionManager
import com.bitchat.android.ui.ChatScreen
import com.bitchat.android.ui.ChatViewModel
import com.bitchat.android.ui.OrientationAwareActivity
import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.nostr.PoWPreferenceManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
class MainActivity : OrientationAwareActivity() {
private lateinit var permissionManager: PermissionManager
private lateinit var onboardingCoordinator: OnboardingCoordinator
private lateinit var bluetoothStatusManager: BluetoothStatusManager
@@ -598,6 +597,9 @@ class MainActivity : ComponentActivity() {
PoWPreferenceManager.init(this@MainActivity)
Log.d("MainActivity", "PoW preferences initialized")
// Initialize Location Notes Manager (extracted to separate file)
com.bitchat.android.nostr.LocationNotesInitializer.initialize(this@MainActivity)
// Ensure all permissions are still granted (user might have revoked in settings)
if (!permissionManager.areAllPermissionsGranted()) {
val missing = permissionManager.getMissingPermissions()
@@ -3,34 +3,109 @@ package com.bitchat.android.features.media
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
object ImageUtils {
fun downscaleAndSaveToAppFiles(context: Context, uri: android.net.Uri, maxDim: Int = 512, quality: Int = 85): String? {
fun downscaleAndSaveToAppFiles(context: Context, uri: Uri, maxDim: Int = 512, quality: Int = 85): String? {
return try {
val resolver = context.contentResolver
val exifRotation = resolver.openInputStream(uri)?.use { getRotationDegreesFromExif(it) } ?: 0
// Reopen for decode as the previous stream is consumed
val input = resolver.openInputStream(uri) ?: return null
val original = BitmapFactory.decodeStream(input)
input.close()
original ?: return null
val w = original.width
val h = original.height
val oriented = if (exifRotation != 0) rotateBitmap(original, exifRotation) else original
val w = oriented.width
val h = oriented.height
val scale = (maxOf(w, h).toFloat() / maxDim.toFloat()).coerceAtLeast(1f)
val newW = (w / scale).toInt().coerceAtLeast(1)
val newH = (h / scale).toInt().coerceAtLeast(1)
val scaled = if (scale > 1f) Bitmap.createScaledBitmap(original, newW, newH, true) else original
val scaled = if (scale > 1f) Bitmap.createScaledBitmap(oriented, newW, newH, true) else oriented
val dir = File(context.filesDir, "images/outgoing").apply { mkdirs() }
val outFile = File(dir, "img_${System.currentTimeMillis()}.jpg")
FileOutputStream(outFile).use { fos ->
scaled.compress(Bitmap.CompressFormat.JPEG, quality, fos)
}
if (scaled !== original) try { original.recycle() } catch (_: Exception) {}
try { if (scaled != original) scaled.recycle() } catch (_: Exception) {}
try { if (oriented !== original) original.recycle() } catch (_: Exception) {}
try { if (scaled !== oriented) oriented.recycle() } catch (_: Exception) {}
outFile.absolutePath
} catch (e: Exception) {
null
}
}
}
fun downscalePathAndSaveToAppFiles(context: Context, path: String, maxDim: Int = 512, quality: Int = 85): String? {
return try {
val original = BitmapFactory.decodeFile(path) ?: return null
val exifRotation = getRotationDegreesFromExif(path)
val oriented = if (exifRotation != 0) rotateBitmap(original, exifRotation) else original
val w = oriented.width
val h = oriented.height
val scale = (maxOf(w, h).toFloat() / maxDim.toFloat()).coerceAtLeast(1f)
val newW = (w / scale).toInt().coerceAtLeast(1)
val newH = (h / scale).toInt().coerceAtLeast(1)
val scaled = if (scale > 1f) Bitmap.createScaledBitmap(oriented, newW, newH, true) else oriented
val dir = File(context.filesDir, "images/outgoing").apply { mkdirs() }
val outFile = File(dir, "img_${System.currentTimeMillis()}.jpg")
FileOutputStream(outFile).use { fos ->
scaled.compress(Bitmap.CompressFormat.JPEG, quality, fos)
}
try { if (oriented !== original) original.recycle() } catch (_: Exception) {}
try { if (scaled !== oriented) oriented.recycle() } catch (_: Exception) {}
outFile.absolutePath
} catch (e: Exception) {
null
}
}
fun loadBitmapWithExifOrientation(path: String): Bitmap? {
return try {
val base = BitmapFactory.decodeFile(path) ?: return null
val rotation = getRotationDegreesFromExif(path)
if (rotation != 0) rotateBitmap(base, rotation) else base
} catch (_: Exception) {
null
}
}
private fun rotateBitmap(src: Bitmap, degrees: Int): Bitmap {
return try {
val m = Matrix()
m.postRotate(degrees.toFloat())
Bitmap.createBitmap(src, 0, 0, src.width, src.height, m, true).also {
try { src.recycle() } catch (_: Exception) {}
}
} catch (_: Exception) {
src
}
}
private fun getRotationDegreesFromExif(path: String): Int = try {
val exif = ExifInterface(path)
orientationToDegrees(exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL))
} catch (_: Exception) { 0 }
private fun getRotationDegreesFromExif(stream: InputStream): Int = try {
val exif = ExifInterface(stream)
orientationToDegrees(exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL))
} catch (_: Exception) { 0 }
private fun orientationToDegrees(orientation: Int): Int = when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90
ExifInterface.ORIENTATION_ROTATE_180 -> 180
ExifInterface.ORIENTATION_ROTATE_270 -> 270
ExifInterface.ORIENTATION_TRANSPOSE -> 90
ExifInterface.ORIENTATION_TRANSVERSE -> 270
else -> 0
}
}
@@ -1,3 +1,4 @@
package com.bitchat.android.features.voice
import android.content.Context
@@ -37,12 +38,13 @@ class VoiceRecorder(private val context: Context) {
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
rec.setAudioChannels(1)
rec.setAudioSamplingRate(44100)
// Lower bitrate to keep BLE payloads <= 32KiB for fragmentation
rec.setAudioEncodingBitRate(32_000)
// Target: 16 kHz AAC @ 20 kbps ≈ 2.5 KB/sec
// Lower sample rate and bitrate for compact, speech-optimized recordings
rec.setAudioSamplingRate(16000)
rec.setAudioEncodingBitRate(20_000)
rec.setOutputFile(file.absolutePath)
rec.prepare()
rec.start()
rec.start()
recorder = rec
outFile = file
file
@@ -115,4 +115,36 @@ object Geohash {
lonMax = maxOf(lonInterval.first, lonInterval.second)
)
}
/**
* Returns the 8 neighboring geohash cells at the same precision as the input.
* Neighbors include N, NE, E, SE, S, SW, W, NW, even when crossing parent cell boundaries.
*/
fun neighborsSamePrecision(geohash: String): Set<String> {
if (geohash.isEmpty()) return emptySet()
val p = geohash.length
val b = decodeToBounds(geohash)
val dLat = b.latMax - b.latMin
val dLon = b.lonMax - b.lonMin
fun wrapLon(lon: Double): Double {
var x = lon
while (x > 180.0) x -= 360.0
while (x < -180.0) x += 360.0
return x
}
val neighbors = mutableSetOf<String>()
for (dy in -1..1) {
for (dx in -1..1) {
if (dx == 0 && dy == 0) continue // skip center
val centerLat = (b.latMin + b.latMax) / 2 + dy * dLat
val rawLonCenter = (b.lonMin + b.lonMax) / 2 + dx * dLon
val centerLon = wrapLon(rawLonCenter)
val enc = encode(centerLat.coerceIn(-90.0, 90.0), centerLon, p)
if (enc.isNotEmpty() && enc != geohash) neighbors.add(enc)
}
}
return neighbors
}
}
@@ -5,6 +5,7 @@ package com.bitchat.android.geohash
* Direct port from iOS implementation for 100% compatibility
*/
enum class GeohashChannelLevel(val precision: Int, val displayName: String) {
BUILDING(8, "Building"), // iOS: precision 8 for building-level (used for Location Notes)
BLOCK(7, "Block"),
NEIGHBORHOOD(6, "Neighborhood"),
CITY(5, "City"),
@@ -88,10 +88,17 @@ class LocationChannelManager private constructor(private val context: Context) {
/**
* Enable location channels (request permission if needed)
* UNIFIED: Only requests location if location services are enabled by user
*/
fun enableLocationChannels() {
Log.d(TAG, "enableLocationChannels() called")
// UNIFIED FIX: Check if location services are enabled by user
if (!isLocationServicesEnabled()) {
Log.w(TAG, "Location services disabled by user - not requesting location")
return
}
when (getCurrentPermissionStatus()) {
PermissionState.NOT_DETERMINED -> {
Log.d(TAG, "Permission not determined - user needs to grant in app settings")
@@ -20,10 +20,10 @@ class BluetoothConnectionTracker(
companion object {
private const val TAG = "BluetoothConnectionTracker"
private const val CONNECTION_RETRY_DELAY = 5000L
private const val MAX_CONNECTION_ATTEMPTS = 3
private const val CLEANUP_DELAY = 500L
private const val CLEANUP_INTERVAL = 30000L // 30 seconds
private const val CONNECTION_RETRY_DELAY = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_RETRY_DELAY_MS
private const val MAX_CONNECTION_ATTEMPTS = com.bitchat.android.util.AppConstants.Mesh.MAX_CONNECTION_ATTEMPTS
private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_CLEANUP_DELAY_MS
private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Mesh.CONNECTION_CLEANUP_INTERVAL_MS // 30 seconds
}
// Connection tracking - reduced memory footprint
@@ -9,6 +9,7 @@ import android.content.Context
import android.os.ParcelUuid
import android.util.Log
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -31,13 +32,6 @@ class BluetoothGattClientManager(
companion object {
private const val TAG = "BluetoothGattClientManager"
// Use exact same UUIDs as iOS version
private val SERVICE_UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
private val CHARACTERISTIC_UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
private val DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
// RSSI monitoring constants
private const val RSSI_UPDATE_INTERVAL = 5000L // 5 seconds
}
// Core Bluetooth components
@@ -182,10 +176,10 @@ class BluetoothGattClientManager(
Log.w(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}")
}
}
delay(RSSI_UPDATE_INTERVAL)
delay(AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS)
} catch (e: Exception) {
Log.w(TAG, "Error in RSSI monitoring: ${e.message}")
delay(RSSI_UPDATE_INTERVAL)
delay(AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS)
}
}
}
@@ -231,12 +225,12 @@ class BluetoothGattClientManager(
}
val scanFilter = ScanFilter.Builder()
.setServiceUuid(ParcelUuid(SERVICE_UUID))
.setServiceUuid(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
.build()
val scanFilters = listOf(scanFilter)
Log.d(TAG, "Starting BLE scan with target service UUID: $SERVICE_UUID")
Log.d(TAG, "Starting BLE scan with target service UUID: ${AppConstants.Mesh.Gatt.SERVICE_UUID}")
scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
@@ -321,7 +315,7 @@ class BluetoothGattClientManager(
val scanRecord = result.scanRecord
// CRITICAL: Only process devices that have our service UUID
val hasOurService = scanRecord?.serviceUuids?.any { it.uuid == SERVICE_UUID } == true
val hasOurService = scanRecord?.serviceUuids?.any { it.uuid == AppConstants.Mesh.Gatt.SERVICE_UUID } == true
if (!hasOurService) {
return
}
@@ -456,9 +450,9 @@ class BluetoothGattClientManager(
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
val service = gatt.getService(SERVICE_UUID)
val service = gatt.getService(AppConstants.Mesh.Gatt.SERVICE_UUID)
if (service != null) {
val characteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
val characteristic = service.getCharacteristic(AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID)
if (characteristic != null) {
connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn ->
val updatedConn = deviceConn.copy(characteristic = characteristic)
@@ -467,7 +461,7 @@ class BluetoothGattClientManager(
}
gatt.setCharacteristicNotification(characteristic, true)
val descriptor = characteristic.getDescriptor(DESCRIPTOR_UUID)
val descriptor = characteristic.getDescriptor(AppConstants.Mesh.Gatt.DESCRIPTOR_UUID)
if (descriptor != null) {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
@@ -9,6 +9,7 @@ import android.content.Context
import android.os.ParcelUuid
import android.util.Log
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -28,10 +29,6 @@ class BluetoothGattServerManager(
companion object {
private const val TAG = "BluetoothGattServerManager"
// Use exact same UUIDs as iOS version
private val SERVICE_UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
private val CHARACTERISTIC_UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
private val DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
}
// Core Bluetooth components
@@ -223,7 +220,7 @@ class BluetoothGattServerManager(
return
}
if (characteristic.uuid == CHARACTERISTIC_UUID) {
if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) {
Log.i(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes")
val packet = BitchatPacket.fromBinaryData(value)
if (packet != null) {
@@ -297,7 +294,7 @@ class BluetoothGattServerManager(
// Create characteristic with notification support
characteristic = BluetoothGattCharacteristic(
CHARACTERISTIC_UUID,
AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID,
BluetoothGattCharacteristic.PROPERTY_READ or
BluetoothGattCharacteristic.PROPERTY_WRITE or
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or
@@ -307,12 +304,12 @@ class BluetoothGattServerManager(
)
val descriptor = BluetoothGattDescriptor(
DESCRIPTOR_UUID,
AppConstants.Mesh.Gatt.DESCRIPTOR_UUID,
BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE
)
characteristic?.addDescriptor(descriptor)
val service = BluetoothGattService(SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
val service = BluetoothGattService(AppConstants.Mesh.Gatt.SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
service.addCharacteristic(characteristic)
gattServer?.addService(service)
@@ -357,7 +354,7 @@ class BluetoothGattServerManager(
val settings = powerManager.getAdvertiseSettings()
val data = AdvertiseData.Builder()
.addServiceUuid(ParcelUuid(SERVICE_UUID))
.addServiceUuid(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
.setIncludeTxPowerLevel(false)
.setIncludeDeviceName(false)
.build()
@@ -36,7 +36,7 @@ class BluetoothMeshService(private val context: Context) {
companion object {
private const val TAG = "BluetoothMeshService"
private const val MAX_TTL: UByte = 7u
private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
}
// Core components - each handling specific responsibilities
@@ -698,7 +698,7 @@ class BluetoothMeshService(private val context: Context) {
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
signature = null,
ttl = 7u
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
)
// Sign and send the encrypted packet
@@ -844,7 +844,7 @@ class BluetoothMeshService(private val context: Context) {
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
signature = null,
ttl = 7u // Same TTL as iOS messageTTL
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS // Same TTL as iOS messageTTL
)
// Sign the packet before broadcasting
@@ -47,7 +47,7 @@ class BluetoothPacketBroadcaster(
companion object {
private const val TAG = "BluetoothPacketBroadcaster"
private const val CLEANUP_DELAY = 500L
private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.BROADCAST_CLEANUP_DELAY_MS
}
// Optional nickname resolver injected by higher layer (peerID -> nickname?)
@@ -22,10 +22,10 @@ class FragmentManager {
companion object {
private const val TAG = "FragmentManager"
// iOS values: 512 MTU threshold, 469 max fragment size (512 MTU - headers)
private const val FRAGMENT_SIZE_THRESHOLD = 512 // Matches iOS: if data.count > 512
private const val MAX_FRAGMENT_SIZE = 469 // Matches iOS: maxFragmentSize = 469
private const val FRAGMENT_TIMEOUT = 30000L // Matches iOS: 30 seconds cleanup
private const val CLEANUP_INTERVAL = 10000L // 10 seconds cleanup check
private const val FRAGMENT_SIZE_THRESHOLD = com.bitchat.android.util.AppConstants.Fragmentation.FRAGMENT_SIZE_THRESHOLD // Matches iOS: if data.count > 512
private const val MAX_FRAGMENT_SIZE = com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENT_SIZE // Matches iOS: maxFragmentSize = 469
private const val FRAGMENT_TIMEOUT = com.bitchat.android.util.AppConstants.Fragmentation.FRAGMENT_TIMEOUT_MS // Matches iOS: 30 seconds cleanup
private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Fragmentation.CLEANUP_INTERVAL_MS // 10 seconds cleanup check
}
// Fragment storage - iOS equivalent: incomingFragments: [String: [Int: Data]]
@@ -180,8 +180,12 @@ class FragmentManager {
incomingFragments.remove(fragmentIDString)
fragmentMetadata.remove(fragmentIDString)
Log.d(TAG, "Successfully reassembled and decoded original packet of ${reassembledData.size} bytes")
return originalPacket
// Suppress re-broadcast of the reassembled packet by zeroing TTL.
// We already relayed the incoming fragments; setting TTL=0 ensures
// PacketRelayManager will skip relaying this reconstructed packet.
val suppressedTtlPacket = originalPacket.copy(ttl = 0u.toUByte())
Log.d(TAG, "Successfully reassembled original (${reassembledData.size} bytes); set TTL=0 to suppress relay")
return suppressedTtlPacket
} else {
val metadata = fragmentMetadata[fragmentIDString]
Log.e(TAG, "Failed to decode reassembled packet (type=${metadata?.first}, total=${metadata?.second})")
@@ -183,16 +183,16 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
}
// Create NOISE_ENCRYPTED packet exactly like iOS
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(senderPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encryptedPayload,
signature = null,
ttl = 7u // Same TTL as iOS messageTTL
)
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(senderPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encryptedPayload,
signature = null,
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS // Same TTL as iOS messageTTL
)
delegate?.sendPacket(packet)
Log.d(TAG, "📤 Sent delivery ACK to $senderPeerID for message $messageID")
@@ -210,6 +210,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
val peerID = routed.peerID ?: "unknown"
if (peerID == myPeerID) return false
// Ignore stale announcements older than STALE_PEER_TIMEOUT
val now = System.currentTimeMillis()
val age = now - packet.timestamp.toLong()
if (age > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
Log.w(TAG, "Ignoring stale ANNOUNCE from ${peerID.take(8)} (age=${age}ms > ${com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS}ms)")
return false
}
// Try to decode as iOS-compatible IdentityAnnouncement with TLV format
val announcement = IdentityAnnouncement.decode(packet.payload)
@@ -317,7 +325,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
timestamp = System.currentTimeMillis().toULong(),
payload = response,
signature = null,
ttl = 7u // Same TTL as iOS
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS // Same TTL as iOS
)
delegate?.sendPacket(responsePacket)
@@ -67,9 +67,10 @@ class PeerManager {
companion object {
private const val TAG = "PeerManager"
private const val STALE_PEER_TIMEOUT = 180000L // 3 minutes (same as iOS)
private const val CLEANUP_INTERVAL = 60000L // 1 minute
}
// Centralized timeout from AppConstants
private val stalePeerTimeoutMs: Long = com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS
// Peer tracking data - enhanced with verification status
private val peers = ConcurrentHashMap<String, PeerInfo>() // peerID -> PeerInfo
@@ -299,7 +300,7 @@ class PeerManager {
fun isPeerActive(peerID: String): Boolean {
val info = peers[peerID] ?: return false
val now = System.currentTimeMillis()
return (now - info.lastSeen) <= STALE_PEER_TIMEOUT && info.isConnected
return (now - info.lastSeen) <= stalePeerTimeoutMs && info.isConnected
}
/**
@@ -328,7 +329,7 @@ class PeerManager {
*/
fun getActivePeerIDs(): List<String> {
val now = System.currentTimeMillis()
return peers.filterValues { (now - it.lastSeen) <= STALE_PEER_TIMEOUT && it.isConnected }
return peers.filterValues { (now - it.lastSeen) <= stalePeerTimeoutMs && it.isConnected }
.keys
.toList()
.sorted()
@@ -414,7 +415,7 @@ class PeerManager {
private fun startPeriodicCleanup() {
managerScope.launch {
while (isActive) {
delay(CLEANUP_INTERVAL)
delay(com.bitchat.android.util.AppConstants.Mesh.PEER_CLEANUP_INTERVAL_MS)
cleanupStalePeers()
}
}
@@ -426,7 +427,7 @@ class PeerManager {
private fun cleanupStalePeers() {
val now = System.currentTimeMillis()
val peersToRemove = peers.filterValues { (now - it.lastSeen) > STALE_PEER_TIMEOUT }
val peersToRemove = peers.filterValues { (now - it.lastSeen) > stalePeerTimeoutMs }
.keys
.toList()
@@ -21,22 +21,22 @@ class PowerManager(private val context: Context) {
private const val TAG = "PowerManager"
// Battery thresholds
private const val CRITICAL_BATTERY = 10
private const val LOW_BATTERY = 20
private const val MEDIUM_BATTERY = 50
private const val CRITICAL_BATTERY = com.bitchat.android.util.AppConstants.Power.CRITICAL_BATTERY_PERCENT
private const val LOW_BATTERY = com.bitchat.android.util.AppConstants.Power.LOW_BATTERY_PERCENT
private const val MEDIUM_BATTERY = com.bitchat.android.util.AppConstants.Power.MEDIUM_BATTERY_PERCENT
// Scan duty cycle periods (ms)
private const val SCAN_ON_DURATION_NORMAL = 8000L // 8 seconds on
private const val SCAN_OFF_DURATION_NORMAL = 2000L // 2 seconds off
private const val SCAN_ON_DURATION_POWER_SAVE = 2000L // 2 seconds on
private const val SCAN_OFF_DURATION_POWER_SAVE = 8000L // 8 seconds off
private const val SCAN_ON_DURATION_ULTRA_LOW = 1000L // 1 second on
private const val SCAN_OFF_DURATION_ULTRA_LOW = 10000L // 10 seconds off
private const val SCAN_ON_DURATION_NORMAL = com.bitchat.android.util.AppConstants.Power.SCAN_ON_DURATION_NORMAL_MS // 8 seconds on
private const val SCAN_OFF_DURATION_NORMAL = com.bitchat.android.util.AppConstants.Power.SCAN_OFF_DURATION_NORMAL_MS // 2 seconds off
private const val SCAN_ON_DURATION_POWER_SAVE = com.bitchat.android.util.AppConstants.Power.SCAN_ON_DURATION_POWER_SAVE_MS // 2 seconds on
private const val SCAN_OFF_DURATION_POWER_SAVE = com.bitchat.android.util.AppConstants.Power.SCAN_OFF_DURATION_POWER_SAVE_MS // 8 seconds off
private const val SCAN_ON_DURATION_ULTRA_LOW = com.bitchat.android.util.AppConstants.Power.SCAN_ON_DURATION_ULTRA_LOW_MS // 1 second on
private const val SCAN_OFF_DURATION_ULTRA_LOW = com.bitchat.android.util.AppConstants.Power.SCAN_OFF_DURATION_ULTRA_LOW_MS // 10 seconds off
// Connection limits
private const val MAX_CONNECTIONS_NORMAL = 8
private const val MAX_CONNECTIONS_POWER_SAVE = 4
private const val MAX_CONNECTIONS_ULTRA_LOW = 2
private const val MAX_CONNECTIONS_NORMAL = com.bitchat.android.util.AppConstants.Power.MAX_CONNECTIONS_NORMAL
private const val MAX_CONNECTIONS_POWER_SAVE = com.bitchat.android.util.AppConstants.Power.MAX_CONNECTIONS_POWER_SAVE
private const val MAX_CONNECTIONS_ULTRA_LOW = com.bitchat.android.util.AppConstants.Power.MAX_CONNECTIONS_ULTRA_LOW
}
enum class PowerMode {
@@ -19,10 +19,10 @@ class SecurityManager(private val encryptionService: EncryptionService, private
companion object {
private const val TAG = "SecurityManager"
private const val MESSAGE_TIMEOUT = 300000L // 5 minutes (same as iOS)
private const val CLEANUP_INTERVAL = 300000L // 5 minutes
private const val MAX_PROCESSED_MESSAGES = 10000
private const val MAX_PROCESSED_KEY_EXCHANGES = 1000
private const val MESSAGE_TIMEOUT = com.bitchat.android.util.AppConstants.Security.MESSAGE_TIMEOUT_MS // 5 minutes (same as iOS)
private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Security.CLEANUP_INTERVAL_MS // 5 minutes
private const val MAX_PROCESSED_MESSAGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_MESSAGES
private const val MAX_PROCESSED_KEY_EXCHANGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_KEY_EXCHANGES
}
// Security tracking
@@ -16,10 +16,10 @@ class StoreForwardManager {
companion object {
private const val TAG = "StoreForwardManager"
private const val MESSAGE_CACHE_TIMEOUT = 43200000L // 12 hours for regular peers
private const val MAX_CACHED_MESSAGES = 100 // For regular peers
private const val MAX_CACHED_MESSAGES_FAVORITES = 1000 // For favorites
private const val CLEANUP_INTERVAL = 600000L // 10 minutes
private const val MESSAGE_CACHE_TIMEOUT = com.bitchat.android.util.AppConstants.StoreForward.MESSAGE_CACHE_TIMEOUT_MS // 12 hours for regular peers
private const val MAX_CACHED_MESSAGES = com.bitchat.android.util.AppConstants.StoreForward.MAX_CACHED_MESSAGES // For regular peers
private const val MAX_CACHED_MESSAGES_FAVORITES = com.bitchat.android.util.AppConstants.StoreForward.MAX_CACHED_MESSAGES_FAVORITES // For favorites
private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.StoreForward.CLEANUP_INTERVAL_MS // 10 minutes
}
/**
@@ -29,11 +29,11 @@ import java.util.concurrent.atomic.AtomicLong
*/
object TorManager {
private const val TAG = "TorManager"
private const val DEFAULT_SOCKS_PORT = 9060
private const val RESTART_DELAY_MS = 2000L // 2 seconds between stop/start
private const val INACTIVITY_TIMEOUT_MS = 5000L // 5 seconds of no activity before restart
private const val MAX_RETRY_ATTEMPTS = 5
private const val STOP_TIMEOUT_MS = 7000L
private const val DEFAULT_SOCKS_PORT = com.bitchat.android.util.AppConstants.Tor.DEFAULT_SOCKS_PORT
private const val RESTART_DELAY_MS = com.bitchat.android.util.AppConstants.Tor.RESTART_DELAY_MS // 2 seconds between stop/start
private const val INACTIVITY_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.INACTIVITY_TIMEOUT_MS // 5 seconds of no activity before restart
private const val MAX_RETRY_ATTEMPTS = com.bitchat.android.util.AppConstants.Tor.MAX_RETRY_ATTEMPTS
private const val STOP_TIMEOUT_MS = com.bitchat.android.util.AppConstants.Tor.STOP_TIMEOUT_MS
private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -24,8 +24,8 @@ class NoiseEncryptionService(private val context: Context) {
private const val TAG = "NoiseEncryptionService"
// Session limits for performance and security
private const val REKEY_TIME_LIMIT = 3600000L // 1 hour (same as iOS)
private const val REKEY_MESSAGE_LIMIT = 1000L // 1k messages (matches iOS) (same as iOS)
private const val REKEY_TIME_LIMIT = com.bitchat.android.util.AppConstants.Noise.REKEY_TIME_LIMIT_MS // 1 hour (same as iOS)
private const val REKEY_MESSAGE_LIMIT = com.bitchat.android.util.AppConstants.Noise.REKEY_MESSAGE_LIMIT_ENCRYPTION // 1k messages (matches iOS) (same as iOS)
}
// Static identity key (persistent across app restarts) - loaded from secure storage
@@ -25,8 +25,8 @@ class NoiseSession(
private const val PROTOCOL_NAME = "Noise_XX_25519_ChaChaPoly_SHA256"
// Rekey thresholds (same as iOS)
private const val REKEY_TIME_LIMIT = 3600000L // 1 hour
private const val REKEY_MESSAGE_LIMIT = 10000L // 10k messages
private const val REKEY_TIME_LIMIT = com.bitchat.android.util.AppConstants.Noise.REKEY_TIME_LIMIT_MS // 1 hour
private const val REKEY_MESSAGE_LIMIT = com.bitchat.android.util.AppConstants.Noise.REKEY_MESSAGE_LIMIT_SESSION // 10k messages
// XX Pattern Message Sizes (exactly matching iOS implementation)
private const val XX_MESSAGE_1_SIZE = 32 // -> e (ephemeral key only)
@@ -34,13 +34,13 @@ class NoiseSession(
private const val XX_MESSAGE_3_SIZE = 48 // -> s, se (encrypted static key)
// Maximum payload size for safety
private const val MAX_PAYLOAD_SIZE = 256
private const val MAX_PAYLOAD_SIZE = com.bitchat.android.util.AppConstants.Noise.MAX_PAYLOAD_SIZE_BYTES
// Constants for replay protection (matching iOS implementation)
private const val NONCE_SIZE_BYTES = 4
private const val REPLAY_WINDOW_SIZE = 1024
private const val REPLAY_WINDOW_BYTES = REPLAY_WINDOW_SIZE / 8 // 128 bytes
private const val HIGH_NONCE_WARNING_THRESHOLD = 1_000_000_000L
private const val HIGH_NONCE_WARNING_THRESHOLD = com.bitchat.android.util.AppConstants.Noise.HIGH_NONCE_WARNING_THRESHOLD
// MARK: - Sliding Window Replay Protection
@@ -0,0 +1,63 @@
package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
/**
* Initializer for LocationNotesManager with all dependencies
* Extracts initialization logic from MainActivity for better separation of concerns
*/
object LocationNotesInitializer {
private const val TAG = "LocationNotesInitializer"
/**
* Initialize LocationNotesManager with all required dependencies
*
* @param context Application context
* @return true if initialization succeeded, false otherwise
*/
fun initialize(context: Context): Boolean {
return try {
LocationNotesManager.getInstance().initialize(
relayManager = { NostrRelayManager.getInstance(context) },
subscribe = { filter, id, handler ->
// CRITICAL FIX: Extract geohash properly from filter using getGeohash() method
val geohashFromFilter = filter.getGeohash() ?: run {
Log.e(TAG, "❌ Cannot extract geohash from filter for location notes")
return@initialize id // Return subscription ID even on error
}
Log.d(TAG, "📍 Location Notes subscribing to geohash: $geohashFromFilter")
NostrRelayManager.getInstance(context).subscribeForGeohash(
geohash = geohashFromFilter,
filter = filter,
id = id,
handler = handler,
includeDefaults = true,
nRelays = 5
)
},
unsubscribe = { id ->
NostrRelayManager.getInstance(context).unsubscribe(id)
},
sendEvent = { event, relayUrls ->
if (relayUrls != null) {
NostrRelayManager.getInstance(context).sendEvent(event, relayUrls)
} else {
NostrRelayManager.getInstance(context).sendEvent(event)
}
},
deriveIdentity = { geohash ->
NostrIdentityBridge.deriveIdentity(geohash, context)
}
)
Log.d(TAG, "✅ Location Notes Manager initialized")
true
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to initialize Location Notes Manager: ${e.message}", e)
false
}
}
}
@@ -0,0 +1,468 @@
package com.bitchat.android.nostr
import android.util.Log
import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.*
/**
* Manages location notes (kind=1 text notes with geohash tags)
* iOS-compatible implementation with LiveData for Android UI binding
*/
@MainThread
class LocationNotesManager private constructor() {
companion object {
private const val TAG = "LocationNotesManager"
private const val MAX_NOTES_IN_MEMORY = 500
@Volatile
private var INSTANCE: LocationNotesManager? = null
fun getInstance(): LocationNotesManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: LocationNotesManager().also { INSTANCE = it }
}
}
}
/**
* Note data class matching iOS implementation
*/
data class Note(
val id: String,
val pubkey: String,
val content: String,
val createdAt: Int,
val nickname: String?
) {
/**
* Display name for the note - matches iOS exactly
* Format: "nickname#abcd" or "anon#abcd" where abcd is last 4 chars of pubkey
*/
val displayName: String
get() {
val suffix = pubkey.takeLast(4)
val nick = nickname?.trim()
return if (!nick.isNullOrEmpty()) {
"$nick#$suffix"
} else {
"anon#$suffix"
}
}
}
/**
* Manager state enum
*/
enum class State {
IDLE,
LOADING,
READY,
NO_RELAYS
}
// Published state (LiveData for Android)
private val _notes = MutableLiveData<List<Note>>(emptyList())
val notes: LiveData<List<Note>> = _notes
private val _geohash = MutableLiveData<String?>(null)
val geohash: LiveData<String?> = _geohash
private val _initialLoadComplete = MutableLiveData(false)
val initialLoadComplete: LiveData<Boolean> = _initialLoadComplete
private val _state = MutableLiveData(State.IDLE)
val state: LiveData<State> = _state
private val _errorMessage = MutableLiveData<String?>(null)
val errorMessage: LiveData<String?> = _errorMessage
// Private state
private var subscriptionIDs: MutableMap<String, String> = mutableMapOf()
private val noteIDs = mutableSetOf<String>() // For deduplication
private var subscribedGeohashes: Set<String> = emptySet()
// Dependencies (injected via setters for flexibility)
private var relayLookup: (() -> NostrRelayManager)? = null
private var subscribeFunc: ((NostrFilter, String, (NostrEvent) -> Unit) -> String)? = null
private var unsubscribeFunc: ((String) -> Unit)? = null
private var sendEventFunc: ((NostrEvent, List<String>?) -> Unit)? = null
private var deriveIdentityFunc: ((String) -> NostrIdentity)? = null
// Coroutine scope for background operations
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
/**
* Initialize dependencies
*/
fun initialize(
relayManager: () -> NostrRelayManager,
subscribe: (NostrFilter, String, (NostrEvent) -> Unit) -> String,
unsubscribe: (String) -> Unit,
sendEvent: (NostrEvent, List<String>?) -> Unit,
deriveIdentity: (String) -> NostrIdentity
) {
this.relayLookup = relayManager
this.subscribeFunc = subscribe
this.unsubscribeFunc = unsubscribe
this.sendEventFunc = sendEvent
this.deriveIdentityFunc = deriveIdentity
}
/**
* Set geohash and start subscription
* iOS: Validates building-level precision (8 characters)
*/
fun setGeohash(newGeohash: String) {
val normalized = newGeohash.lowercase()
if (_geohash.value == normalized) {
Log.d(TAG, "Geohash unchanged, skipping: $normalized")
return
}
// Validate geohash (building-level precision: 8 chars) - matches iOS
if (!isValidBuildingGeohash(normalized)) {
Log.w(TAG, "LocationNotesManager: rejecting invalid geohash '$normalized' (expected 8 valid base32 chars)")
return
}
Log.d(TAG, "Setting geohash: $normalized")
// Cancel existing subscription
cancel()
// Set loading state before clearing to prevent empty state flicker (iOS pattern)
_state.value = State.LOADING
_initialLoadComplete.value = false
_errorMessage.value = null
// Clear notes
_notes.value = emptyList()
noteIDs.clear()
_geohash.value = normalized
// Compute target geohashes: center + neighbors (±1)
val neighbors = try {
com.bitchat.android.geohash.Geohash.neighborsSamePrecision(normalized)
} catch (_: Exception) { emptySet() }
subscribedGeohashes = (neighbors + normalized).toSet()
// Start new subscriptions for all cells
subscribeAll()
}
/**
* Validate building-level geohash (precision 8) - matches iOS Geohash.isValidBuildingGeohash
*/
private fun isValidBuildingGeohash(geohash: String): Boolean {
if (geohash.length != 8) return false
val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz"
return geohash.all { it in base32Chars }
}
/**
* Refresh notes for current geohash
*/
fun refresh() {
val currentGeohash = _geohash.value
if (currentGeohash == null) {
Log.w(TAG, "Cannot refresh - no geohash set")
return
}
Log.d(TAG, "Refreshing notes for geohash: $currentGeohash")
// Cancel and restart subscriptions for current ±1 set
cancel()
_notes.value = emptyList()
noteIDs.clear()
_initialLoadComplete.value = false
// Rebuild subscribedGeohashes and resubscribe
val neighbors = try {
com.bitchat.android.geohash.Geohash.neighborsSamePrecision(currentGeohash)
} catch (_: Exception) { emptySet() }
subscribedGeohashes = (neighbors + currentGeohash).toSet()
subscribeAll()
}
/**
* Send a new location note
*/
fun send(content: String, nickname: String?) {
val currentGeohash = _geohash.value
if (currentGeohash == null) {
Log.w(TAG, "Cannot send note - no geohash set")
_errorMessage.value = "No location set"
return
}
val trimmed = content.trim()
if (trimmed.isEmpty()) {
return
}
// CRITICAL FIX: Get geo-specific relays for sending (matching iOS pattern)
// iOS: let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
val relays = try {
com.bitchat.android.nostr.RelayDirectory.closestRelaysForGeohash(currentGeohash, 5)
} catch (e: Exception) {
Log.e(TAG, "Failed to lookup relays for geohash $currentGeohash: ${e.message}")
emptyList()
}
// Check if we have relays (iOS pattern: guard !relays.isEmpty())
if (relays.isEmpty()) {
Log.w(TAG, "Send blocked - no geo relays for geohash: $currentGeohash")
_state.value = State.NO_RELAYS
_errorMessage.value = "No relays available"
return
}
val deriveIdentity = deriveIdentityFunc
if (deriveIdentity == null) {
Log.e(TAG, "Cannot send note - deriveIdentity not initialized")
_errorMessage.value = "Not initialized"
return
}
Log.d(TAG, "Sending note to geohash: $currentGeohash via ${relays.size} geo relays")
scope.launch {
try {
val identity = withContext(Dispatchers.IO) {
deriveIdentity(currentGeohash)
}
val event = withContext(Dispatchers.IO) {
NostrProtocol.createGeohashTextNote(
content = trimmed,
geohash = currentGeohash,
senderIdentity = identity,
nickname = nickname
)
}
// Optimistic local echo - add note immediately to UI
val localNote = Note(
id = event.id,
pubkey = event.pubkey,
content = trimmed,
createdAt = event.createdAt,
nickname = nickname
)
if (!noteIDs.contains(event.id)) {
noteIDs.add(event.id)
val currentNotes = _notes.value ?: emptyList()
_notes.value = (currentNotes + localNote).sortedByDescending { it.createdAt }
// Trim if exceeds max
if (noteIDs.size > MAX_NOTES_IN_MEMORY) {
trimOldestNotes()
}
}
// CRITICAL FIX: Send to geo-specific relays (matching iOS pattern)
// iOS: dependencies.sendEvent(event, relays)
withContext(Dispatchers.IO) {
sendEventFunc?.invoke(event, relays)
}
Log.d(TAG, "✅ Note sent successfully to ${relays.size} geo relays: ${event.id.take(16)}...")
// Clear any error messages on successful send
_errorMessage.value = null
_state.value = State.READY
} catch (e: Exception) {
Log.e(TAG, "Failed to send note: ${e.message}")
_errorMessage.value = "Failed to send: ${e.message}"
}
}
}
/**
* Subscribe to location notes for current geohash
*/
private fun subscribeAll() {
val currentGeohash = _geohash.value
if (currentGeohash == null) {
Log.w(TAG, "Cannot subscribe - no geohash set")
_state.value = State.IDLE
return
}
val subscribe = subscribeFunc
if (subscribe == null) {
Log.e(TAG, "Cannot subscribe - subscribe function not initialized; will retry shortly")
_state.value = State.LOADING
// Retry a few times in case initialization is racing the sheet open
scope.launch {
var attempts = 0
while (attempts < 10 && subscribeFunc == null) {
delay(300)
attempts++
}
val subNow = subscribeFunc
if (subNow != null) {
// Try again now that dependencies are ready
subscribeAll()
} else {
// Give UI a chance to show empty state rather than spinner forever
if (!_initialLoadComplete.value!!) {
_initialLoadComplete.value = true
_state.value = State.READY
}
}
}
return
}
_state.value = State.LOADING
// Subscribe for each geohash in the ±1 set
subscribedGeohashes.forEach { gh ->
val filter = NostrFilter.geohashNotes(
geohash = gh,
since = null,
limit = 200
)
val subId = "location-notes-$gh"
Log.d(TAG, "📡 Subscribing to location notes: $subId")
try {
val id = subscribe(filter, subId) { event -> handleEvent(event) }
subscriptionIDs[gh] = id
} catch (e: Exception) {
Log.e(TAG, "Failed to subscribe for $gh: ${e.message}")
}
}
// Mark initial load complete after brief delay to allow relay responses
scope.launch {
delay(2000) // Wait 2 seconds for initial batch
if (!_initialLoadComplete.value!!) {
_initialLoadComplete.value = true
_state.value = State.READY
Log.d(TAG, "Initial load complete for geohash: $currentGeohash (${noteIDs.size} notes)")
}
}
}
/**
* Handle incoming event from subscription
*/
private fun handleEvent(event: NostrEvent) {
// Validate event
if (event.kind != NostrKind.TEXT_NOTE) {
Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}")
return
}
// Check for geohash tag
val geohashTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }
if (geohashTag == null) {
Log.v(TAG, "Ignoring event without geohash tag: ${event.id.take(16)}...")
return
}
// Check if matches current geohash
val eventGeohash = geohashTag[1]
if (!subscribedGeohashes.contains(eventGeohash)) {
Log.v(TAG, "Ignoring event for non-subscribed geohash: $eventGeohash")
return
}
// Deduplicate
if (noteIDs.contains(event.id)) {
return
}
// Extract nickname from tags
val nicknameTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "n" }
val nickname = nicknameTag?.get(1)
// Create note
val note = Note(
id = event.id,
pubkey = event.pubkey,
content = event.content,
createdAt = event.createdAt,
nickname = nickname
)
// Add to collection
noteIDs.add(event.id)
val currentNotes = _notes.value ?: emptyList()
_notes.value = (currentNotes + note).sortedByDescending { it.createdAt }
Log.d(TAG, "📥 Added note: ${note.displayName} - ${note.content.take(50)}")
// Trim if exceeds max
if (noteIDs.size > MAX_NOTES_IN_MEMORY) {
trimOldestNotes()
}
// Update state
if (!_initialLoadComplete.value!!) {
_initialLoadComplete.value = true
}
_state.value = State.READY
}
/**
* Trim oldest notes to stay within memory limit
*/
private fun trimOldestNotes() {
val currentNotes = _notes.value ?: return
if (currentNotes.size <= MAX_NOTES_IN_MEMORY) return
val trimmed = currentNotes.sortedByDescending { it.createdAt }.take(MAX_NOTES_IN_MEMORY)
_notes.value = trimmed
// Update note IDs set
noteIDs.clear()
noteIDs.addAll(trimmed.map { it.id })
Log.d(TAG, "Trimmed notes to $MAX_NOTES_IN_MEMORY (removed ${currentNotes.size - trimmed.size})")
}
/**
* Clear error message - matches iOS clearError()
*/
fun clearError() {
_errorMessage.value = null
}
/**
* Cancel subscription and clear state
*/
fun cancel() {
if (subscriptionIDs.isNotEmpty()) {
subscriptionIDs.values.forEach { subId ->
try {
Log.d(TAG, "🚫 Canceling subscription: $subId")
unsubscribeFunc?.invoke(subId)
} catch (_: Exception) { }
}
subscriptionIDs.clear()
}
subscribedGeohashes = emptySet()
_state.value = State.IDLE
}
/**
* Cleanup resources
*/
fun cleanup() {
cancel()
scope.cancel()
_notes.value = emptyList()
noteIDs.clear()
_geohash.value = null
_initialLoadComplete.value = false
_errorMessage.value = null
}
}
@@ -46,7 +46,7 @@ object NostrEmbeddedBitChat {
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
signature = null,
ttl = 7u
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
)
val data = packet.toBinaryData() ?: return null
@@ -86,7 +86,7 @@ object NostrEmbeddedBitChat {
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
signature = null,
ttl = 7u
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
)
val data = packet.toBinaryData() ?: return null
@@ -123,7 +123,7 @@ object NostrEmbeddedBitChat {
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
signature = null,
ttl = 7u
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
)
val data = packet.toBinaryData() ?: return null
@@ -158,7 +158,7 @@ object NostrEmbeddedBitChat {
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
signature = null,
ttl = 7u
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
)
val data = packet.toBinaryData() ?: return null
@@ -22,7 +22,7 @@ class NostrEventDeduplicator(
) {
companion object {
private const val TAG = "NostrDeduplicator"
private const val DEFAULT_CAPACITY = 10000
private const val DEFAULT_CAPACITY = com.bitchat.android.util.AppConstants.Nostr.DEFAULT_DEDUP_CAPACITY
@Volatile
private var INSTANCE: NostrEventDeduplicator? = null
@@ -55,6 +55,18 @@ data class NostrFilter(
)
}
/**
* Create filter for geohash-scoped text notes (kind=1 with g tag)
*/
fun geohashNotes(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter {
return NostrFilter(
kinds = listOf(NostrKind.TEXT_NOTE),
since = since?.let { (it / 1000).toInt() },
tagFilters = mapOf("g" to listOf(geohash)),
limit = limit
)
}
/**
* Create filter for specific event IDs
*/
@@ -193,4 +205,12 @@ data class NostrFilter(
return "NostrFilter(${parts.joinToString(", ")})"
}
/**
* Get geohash value from g tag filter (if present)
* Returns the first geohash in the filter or null if none
*/
fun getGeohash(): String? {
return tagFilters?.get("g")?.firstOrNull()
}
}
@@ -90,6 +90,34 @@ object NostrProtocol {
}
}
/**
* Create a geohash-scoped text note (kind 1) with optional nickname
* This creates a persistent text note that can be retrieved later
*/
suspend fun createGeohashTextNote(
content: String,
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = null
): NostrEvent = withContext(Dispatchers.Default) {
val tags = mutableListOf<List<String>>()
tags.add(listOf("g", geohash))
if (!nickname.isNullOrEmpty()) {
tags.add(listOf("n", nickname))
}
val event = NostrEvent(
pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.TEXT_NOTE,
tags = tags,
content = content
)
return@withContext senderIdentity.signEvent(event)
}
/**
* Create a geohash-scoped ephemeral public message (kind 20000)
* Includes Proof of Work mining if enabled in settings
@@ -41,10 +41,10 @@ class NostrRelayManager private constructor() {
)
// Exponential backoff configuration (same as iOS)
private const val INITIAL_BACKOFF_INTERVAL = 1000L // 1 second
private const val MAX_BACKOFF_INTERVAL = 300000L // 5 minutes
private const val BACKOFF_MULTIPLIER = 2.0
private const val MAX_RECONNECT_ATTEMPTS = 10
private const val INITIAL_BACKOFF_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS // 1 second
private const val MAX_BACKOFF_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS // 5 minutes
private const val BACKOFF_MULTIPLIER = com.bitchat.android.util.AppConstants.Nostr.BACKOFF_MULTIPLIER
private const val MAX_RECONNECT_ATTEMPTS = com.bitchat.android.util.AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS
// Track gift-wraps we initiated for logging
private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet<String>()
@@ -111,7 +111,7 @@ class NostrRelayManager private constructor() {
// Subscription validation timer
private var subscriptionValidationJob: Job? = null
private val SUBSCRIPTION_VALIDATION_INTERVAL = 30000L // 30 seconds
private val SUBSCRIPTION_VALIDATION_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.SUBSCRIPTION_VALIDATION_INTERVAL_MS // 30 seconds
// OkHttp client for WebSocket connections (via provider to honor Tor)
private val httpClient: OkHttpClient
@@ -19,7 +19,7 @@ class NostrTransport(
companion object {
private const val TAG = "NostrTransport"
private const val READ_ACK_INTERVAL = 350L // ~3 per second (0.35s interval like iOS)
private const val READ_ACK_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.READ_ACK_INTERVAL_MS // ~3 per second (0.35s interval like iOS)
@Volatile
private var INSTANCE: NostrTransport? = null
@@ -102,8 +102,8 @@ private fun BatteryOptimizationEnabledContent(
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "bitchat",
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -112,8 +112,8 @@ private fun BatteryOptimizationEnabledContent(
color = colorScheme.onBackground
)
Text(
text = "battery optimization detected",
Text(
text = stringResource(R.string.battery_optimization_detected_title),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.7f)
@@ -136,22 +136,22 @@ private fun BatteryOptimizationEnabledContent(
) {
Icon(
imageVector = Icons.Filled.Power,
contentDescription = "Battery Optimization",
contentDescription = stringResource(R.string.cd_battery_optimization),
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp)
)
Column {
Text(
text = "Battery Optimization Enabled",
Text(
text = stringResource(R.string.battery_optimization_enabled_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "bitchat needs to run in the background to maintain mesh connections. battery optimization can interrupt these connections.",
Text(
text = stringResource(R.string.battery_optimization_explanation_short),
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onBackground.copy(alpha = 0.8f)
)
@@ -176,22 +176,22 @@ private fun BatteryOptimizationEnabledContent(
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = "Benefits",
contentDescription = stringResource(R.string.cd_benefits),
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp)
)
Column {
Text(
text = "Benefits of Disabling",
Text(
text = stringResource(R.string.benefits_of_disabling),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "• reliable message delivery\n• maintains mesh connectivity\n• prevents connection drops",
Text(
text = stringResource(R.string.battery_benefits_short),
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onBackground.copy(alpha = 0.8f)
)
@@ -222,8 +222,8 @@ private fun BatteryOptimizationEnabledContent(
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = "Disable Battery Optimization",
Text(
text = stringResource(R.string.disable_battery_optimization),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
@@ -240,8 +240,8 @@ private fun BatteryOptimizationEnabledContent(
modifier = Modifier.weight(1f),
enabled = !isLoading
) {
Text(
text = "Check Again",
Text(
text = stringResource(R.string.check_again),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
)
@@ -256,8 +256,8 @@ private fun BatteryOptimizationEnabledContent(
modifier = Modifier.weight(1f),
enabled = !isLoading
) {
Text(
text = "Skip for Now",
Text(
text = stringResource(R.string.battery_optimization_skip),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
)
@@ -282,7 +282,7 @@ private fun BatteryOptimizationCheckingContent(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "bitchat",
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -291,8 +291,8 @@ private fun BatteryOptimizationCheckingContent(
color = colorScheme.onBackground
)
Text(
text = "battery optimization disabled",
Text(
text = stringResource(R.string.battery_optimization_disabled_title),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.7f)
@@ -312,15 +312,15 @@ private fun BatteryOptimizationCheckingContent(
Icon(
imageVector = Icons.Filled.BatteryStd,
contentDescription = "Checking Battery Optimization",
contentDescription = stringResource(R.string.cd_checking_battery_optimization),
modifier = Modifier
.size(64.dp)
.rotate(rotation),
tint = colorScheme.primary
)
Text(
text = "bitchat can run reliably in the background",
Text(
text = stringResource(R.string.battery_optimization_success_message),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.8f)
@@ -345,7 +345,7 @@ private fun BatteryOptimizationNotSupportedContent(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "bitchat",
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -355,7 +355,7 @@ private fun BatteryOptimizationNotSupportedContent(
)
Text(
text = "battery optimization not required",
text = stringResource(R.string.battery_optimization_not_required),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.7f)
@@ -364,13 +364,13 @@ private fun BatteryOptimizationNotSupportedContent(
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = "Battery Optimization Not Supported",
contentDescription = stringResource(R.string.cd_not_supported_battery_optimization),
modifier = Modifier.size(64.dp),
tint = colorScheme.primary
)
Text(
text = "your device doesn't require battery optimization settings. bitchat will run normally.",
text = stringResource(R.string.battery_optimization_not_supported_message),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.8f)
@@ -385,8 +385,8 @@ private fun BatteryOptimizationNotSupportedContent(
containerColor = colorScheme.primary
)
) {
Text(
text = "Continue",
Text(
text = stringResource(R.string.continue_btn),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
@@ -394,4 +394,4 @@ private fun BatteryOptimizationNotSupportedContent(
)
}
}
}
}
@@ -14,6 +14,8 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
/**
* Screen shown when checking Bluetooth status or requesting Bluetooth enable
@@ -69,13 +71,13 @@ private fun BluetoothDisabledContent(
// Bluetooth icon - using Bluetooth outlined icon in app's green color
Icon(
imageVector = Icons.Outlined.Bluetooth,
contentDescription = "Bluetooth",
contentDescription = stringResource(R.string.cd_bluetooth),
modifier = Modifier.size(64.dp),
tint = Color(0xFF00C851) // App's main green color
)
Text(
text = "Bluetooth Required",
text = stringResource(R.string.bluetooth_required),
style = MaterialTheme.typography.headlineSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -95,8 +97,8 @@ private fun BluetoothDisabledContent(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "bitchat needs Bluetooth to:",
Text(
text = stringResource(R.string.bluetooth_needs_for),
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
@@ -105,11 +107,8 @@ private fun BluetoothDisabledContent(
modifier = Modifier.fillMaxWidth()
)
Text(
text = "• Discover nearby users\n" +
"• Create mesh network connections\n" +
"• Send and receive messages\n" +
"• Work without internet or servers",
Text(
text = stringResource(R.string.bluetooth_needs_bullets),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f)
@@ -132,8 +131,8 @@ private fun BluetoothDisabledContent(
containerColor = Color(0xFF00C851) // App's main green color
)
) {
Text(
text = "Enable Bluetooth",
Text(
text = stringResource(R.string.enable_bluetooth),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
@@ -177,14 +176,14 @@ private fun BluetoothNotSupportedContent(
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Text(
text = "",
text = stringResource(R.string.warning_emoji),
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(16.dp)
)
}
Text(
text = "Bluetooth Not Supported",
text = stringResource(R.string.bluetooth_not_supported),
style = MaterialTheme.typography.headlineSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -201,7 +200,7 @@ private fun BluetoothNotSupportedContent(
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Text(
text = "This device doesn't support Bluetooth Low Energy (BLE), which is required for bitchat to function.\n\nbitchat needs BLE to create mesh networks and communicate with nearby devices without internet.",
text = stringResource(R.string.bluetooth_unsupported_explanation),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface
@@ -222,7 +221,7 @@ private fun BluetoothCheckingContent(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "bitchat",
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -234,7 +233,7 @@ private fun BluetoothCheckingContent(
BluetoothLoadingIndicator()
Text(
text = "Checking Bluetooth status...",
text = stringResource(R.string.checking_bluetooth_status),
style = MaterialTheme.typography.bodyLarge.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.7f)
@@ -12,6 +12,8 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
/**
* Loading screen shown during app initialization after permissions are granted
@@ -59,7 +61,7 @@ fun InitializingScreen(modifier: Modifier) {
) {
// App title
Text(
text = "bitchat",
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -88,7 +90,7 @@ fun InitializingScreen(modifier: Modifier) {
horizontalArrangement = Arrangement.Center
) {
Text(
text = "Initializing mesh network",
text = stringResource(R.string.initializing_mesh_network),
style = MaterialTheme.typography.bodyLarge.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.7f)
@@ -98,7 +100,7 @@ fun InitializingScreen(modifier: Modifier) {
// Animated dots
dots.forEach { alpha ->
Text(
text = ".",
text = stringResource(R.string.dot),
style = MaterialTheme.typography.bodyLarge.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = alpha)
@@ -123,7 +125,7 @@ fun InitializingScreen(modifier: Modifier) {
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Setting up Bluetooth mesh networking...",
text = stringResource(R.string.setting_up_bluetooth),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f)
@@ -132,7 +134,7 @@ fun InitializingScreen(modifier: Modifier) {
)
Text(
text = "This should only take a few seconds",
text = stringResource(R.string.should_take_seconds),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.6f)
@@ -173,14 +175,14 @@ fun InitializationErrorScreen(
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Text(
text = "⚠️",
text = stringResource(R.string.warning_emoji),
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(16.dp)
)
}
Text(
text = "Setup Not Complete",
text = stringResource(R.string.setup_not_complete),
style = MaterialTheme.typography.headlineSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -216,7 +218,7 @@ fun InitializationErrorScreen(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Try Again",
text = stringResource(R.string.try_again),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
@@ -230,7 +232,7 @@ fun InitializationErrorScreen(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Open Settings",
text = stringResource(R.string.open_settings),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
@@ -15,6 +15,8 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
/**
* Screen shown when checking location services status or requesting location services enable
@@ -70,13 +72,13 @@ private fun LocationDisabledContent(
// Location icon - using LocationOn outlined icon in app's green color
Icon(
imageVector = Icons.Outlined.LocationOn,
contentDescription = "Location Services",
contentDescription = stringResource(R.string.cd_location_services),
modifier = Modifier.size(64.dp),
tint = Color(0xFF00C851) // App's main green color
)
Text(
text = "Location Services Required",
text = stringResource(R.string.location_services_required),
style = MaterialTheme.typography.headlineSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -103,13 +105,13 @@ private fun LocationDisabledContent(
) {
Icon(
imageVector = Icons.Filled.Security,
contentDescription = "Privacy",
contentDescription = stringResource(R.string.cd_privacy),
tint = Color(0xFF4CAF50),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Privacy First",
Text(
text = stringResource(R.string.privacy_first),
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold,
color = colorScheme.onSurface
@@ -117,8 +119,8 @@ private fun LocationDisabledContent(
)
}
Text(
text = "bitchat does NOT track your location.\n\nLocation services are required for Bluetooth scanning and for the Geohash chat feature.",
Text(
text = stringResource(R.string.location_explanation),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f)
@@ -127,8 +129,8 @@ private fun LocationDisabledContent(
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "bitchat needs location services for:",
Text(
text = stringResource(R.string.location_needs_for),
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
@@ -137,11 +139,8 @@ private fun LocationDisabledContent(
modifier = Modifier.fillMaxWidth()
)
Text(
text = "• Bluetooth device scanning\n" +
"• Discovering nearby users on mesh network\n" +
"• Geohash chat feature\n" +
"• No tracking or location collection",
Text(
text = stringResource(R.string.location_needs_bullets),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f)
@@ -164,8 +163,8 @@ private fun LocationDisabledContent(
containerColor = Color(0xFF00C851) // App's main green color
)
) {
Text(
text = "Open Location Settings",
Text(
text = stringResource(R.string.open_location_settings),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
@@ -178,8 +177,8 @@ private fun LocationDisabledContent(
onClick = onRetry,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Check Again",
Text(
text = stringResource(R.string.check_again),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
@@ -202,13 +201,13 @@ private fun LocationNotAvailableContent(
// Error icon
Icon(
imageVector = Icons.Filled.ErrorOutline,
contentDescription = "Error",
contentDescription = stringResource(R.string.cd_error),
modifier = Modifier.size(64.dp),
tint = colorScheme.error
)
Text(
text = "Location Services Unavailable",
text = stringResource(R.string.location_services_unavailable),
style = MaterialTheme.typography.headlineSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -225,7 +224,7 @@ private fun LocationNotAvailableContent(
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Text(
text = "Location services are not available on this device. This is unusual as location services are standard on Android devices.\n\nbitchat needs location services for Bluetooth scanning to work properly (Android requirement). Without this, the app cannot discover nearby users.",
text = stringResource(R.string.location_unavailable_explanation),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface
@@ -246,7 +245,7 @@ private fun LocationCheckingContent(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "bitchat",
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -258,7 +257,7 @@ private fun LocationCheckingContent(
LocationLoadingIndicator()
Text(
text = "Checking location services...",
text = stringResource(R.string.checking_location_services),
style = MaterialTheme.typography.bodyLarge.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.7f)
@@ -24,6 +24,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
/**
* Permission explanation screen shown before requesting permissions
@@ -65,7 +67,7 @@ fun PermissionExplanationScreen(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "bitchat",
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -76,7 +78,7 @@ fun PermissionExplanationScreen(
}
Text(
text = "decentralized mesh messaging with end-to-end encryption",
text = stringResource(R.string.about_tagline),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.7f)
@@ -99,7 +101,7 @@ fun PermissionExplanationScreen(
) {
Icon(
imageVector = Icons.Filled.Security,
contentDescription = "Privacy Protected",
contentDescription = stringResource(R.string.cd_privacy_protected),
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
@@ -107,16 +109,14 @@ fun PermissionExplanationScreen(
)
Column {
Text(
text = "Your Privacy is Protected",
text = stringResource(R.string.privacy_protected),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "• no tracking or data collection\n" +
"• Bluetooth mesh chats are fully offline\n" +
"• Geohash chats use the internet",
text = stringResource(R.string.privacy_bullets),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.8f)
@@ -128,7 +128,7 @@ fun PermissionExplanationScreen(
// Section header
Text(
text = "permissions",
text = stringResource(R.string.permissions_header),
style = MaterialTheme.typography.labelLarge,
color = colorScheme.onBackground.copy(alpha = 0.7f),
modifier = Modifier.padding(top = 8.dp, bottom = 8.dp)
@@ -163,7 +163,7 @@ fun PermissionExplanationScreen(
)
) {
Text(
text = "Grant Permissions",
text = stringResource(R.string.grant_permissions),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
@@ -218,12 +218,12 @@ private fun PermissionCategoryCard(
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = "Warning",
contentDescription = stringResource(R.string.cd_warning),
tint = Color(0xFFFF9800),
modifier = Modifier.size(16.dp)
)
Text(
text = "bitchat does NOT track your location",
text = stringResource(R.string.location_tracking_warning),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
@@ -98,8 +98,8 @@ data class BitchatPacket(
timestamp = timestamp,
payload = payload,
signature = null, // Remove signature for signing
ttl = 0u, // Use fixed TTL=0 for signing to ensure relay compatibility
route = route
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // Use fixed TTL=0 for signing to ensure relay compatibility
)
return BinaryProtocol.encode(unsignedPacket)
}
@@ -10,7 +10,7 @@ import java.util.zip.Inflater
* Uses the same zlib algorithm as iOS CompressionUtil.swift
*/
object CompressionUtil {
private const val COMPRESSION_THRESHOLD = 100 // bytes - same as iOS
private const val COMPRESSION_THRESHOLD = com.bitchat.android.util.AppConstants.Protocol.COMPRESSION_THRESHOLD_BYTES // bytes - same as iOS
/**
* Helper to check if compression is worth it - exact same logic as iOS
@@ -13,7 +13,7 @@ class SeenMessageStore private constructor(private val context: Context) {
companion object {
private const val TAG = "SeenMessageStore"
private const val STORAGE_KEY = "seen_message_store_v1"
private const val MAX_IDS = 10_000
private const val MAX_IDS = com.bitchat.android.util.AppConstants.Services.SEEN_MESSAGE_MAX_IDS
@Volatile private var INSTANCE: SeenMessageStore? = null
fun getInstance(appContext: Context): SeenMessageStore {
@@ -86,4 +86,3 @@ class SeenMessageStore private constructor(private val context: Context) {
val read: List<String> = emptyList()
)
}
@@ -31,7 +31,9 @@ class GossipSyncManager(
fun gcsTargetFpr(): Double // percent -> 0.0..1.0
}
companion object { private const val TAG = "GossipSyncManager" }
companion object {
private const val TAG = "GossipSyncManager"
}
var delegate: Delegate? = null
@@ -46,6 +48,7 @@ class GossipSyncManager(
private val latestAnnouncementByPeer = ConcurrentHashMap<String, Pair<String, BitchatPacket>>()
private var periodicJob: Job? = null
private var cleanupJob: Job? = null
fun start() {
periodicJob?.cancel()
periodicJob = scope.launch(Dispatchers.IO) {
@@ -57,10 +60,23 @@ class GossipSyncManager(
catch (e: Exception) { Log.e(TAG, "Periodic sync error: ${e.message}") }
}
}
// Start periodic cleanup of stale announcements and messages
cleanupJob?.cancel()
cleanupJob = scope.launch(Dispatchers.IO) {
while (isActive) {
try {
delay(com.bitchat.android.util.AppConstants.Sync.CLEANUP_INTERVAL_MS)
pruneStaleAnnouncements()
} catch (e: CancellationException) { throw e }
catch (e: Exception) { Log.e(TAG, "Periodic cleanup error: ${e.message}") }
}
}
}
fun stop() {
periodicJob?.cancel(); periodicJob = null
cleanupJob?.cancel(); cleanupJob = null
}
fun scheduleInitialSync(delayMs: Long = 5_000L) {
@@ -98,6 +114,13 @@ class GossipSyncManager(
}
}
} else if (isAnnouncement) {
// Ignore stale announcements older than STALE_PEER_TIMEOUT
val now = System.currentTimeMillis()
val age = now - packet.timestamp.toLong()
if (age > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
Log.d(TAG, "Ignoring stale ANNOUNCE (age=${age}ms > ${com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS}ms)")
return
}
// senderID is fixed-size 8 bytes; map to hex string for key
val sender = packet.senderID.joinToString("") { b -> "%02x".format(b) }
latestAnnouncementByPeer[sender] = id to packet
@@ -118,7 +141,7 @@ class GossipSyncManager(
senderID = hexStringToByteArray(myPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
ttl = 0u // neighbors only
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbors only
)
// Sign and broadcast
val signed = delegate?.signPacketForBroadcast(packet) ?: packet
@@ -134,7 +157,7 @@ class GossipSyncManager(
recipientID = hexStringToByteArray(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
ttl = 0u // neighbor only
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbor only
)
Log.d(TAG, "Sending sync request to $peerID (${payload.size} bytes)")
// Sign and send directly to peer
@@ -162,7 +185,7 @@ class GossipSyncManager(
val idBytes = hexToBytes(id)
if (!mightContain(idBytes)) {
// Send original packet unchanged to requester only (keep local TTL)
val toSend = pkt.copy(ttl = 0u)
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
delegate?.sendPacketToPeer(fromPeerID, toSend)
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}")
}
@@ -173,7 +196,7 @@ class GossipSyncManager(
for (pkt in toSendMsgs) {
val idBytes = PacketIdUtil.computeIdBytes(pkt)
if (!mightContain(idBytes)) {
val toSend = pkt.copy(ttl = 0u)
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
delegate?.sendPacketToPeer(fromPeerID, toSend)
Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}")
}
@@ -235,6 +258,42 @@ class GossipSyncManager(
return RequestSyncPacket(p = params.p, m = mVal, data = params.data).encode()
}
// Periodically remove stale announcements and all their messages
private fun pruneStaleAnnouncements() {
val now = System.currentTimeMillis()
val stalePeers = mutableListOf<String>()
// Identify stale announcements by age
for ((peerID, pair) in latestAnnouncementByPeer.entries) {
val pkt = pair.second
val age = now - pkt.timestamp.toLong()
if (age > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
stalePeers.add(peerID)
}
}
if (stalePeers.isEmpty()) return
// Remove announcements and their messages
var totalPrunedMsgs = 0
for (peerID in stalePeers) {
// Count messages to be pruned for logging
val toRemove = mutableListOf<String>()
synchronized(messages) {
for ((id, message) in messages) {
val sender = message.senderID.joinToString("") { b -> "%02x".format(b) }
if (sender == peerID) toRemove.add(id)
}
}
totalPrunedMsgs += toRemove.size
// Reuse existing removal which also clears announcement entry
removeAnnouncementForPeer(peerID)
}
Log.d(TAG, "Pruned ${stalePeers.size} stale announcements and $totalPrunedMsgs messages")
}
// Explicitly remove stored announcement for a given peer (hex ID)
fun removeAnnouncementForPeer(peerID: String) {
val key = peerID.lowercase()
@@ -244,16 +303,20 @@ class GossipSyncManager(
// Collect IDs to remove first to avoid modifying collection while iterating
val idsToRemove = mutableListOf<String>()
for ((id, message) in messages) {
val sender = message.senderID.joinToString("") { b -> "%02x".format(b) }
if (sender == key) {
idsToRemove.add(id)
synchronized(messages) {
for ((id, message) in messages) {
val sender = message.senderID.joinToString("") { b -> "%02x".format(b) }
if (sender == key) {
idsToRemove.add(id)
}
}
}
// Now remove the collected IDs
for (id in idsToRemove) {
messages.remove(id)
synchronized(messages) {
for (id in idsToRemove) {
messages.remove(id)
}
}
if (idsToRemove.isNotEmpty()) {
@@ -29,7 +29,8 @@ 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 com.bitchat.android.R
/**
* About Sheet for bitchat app information
* Matches the design language of LocationChannelsSheet
@@ -102,7 +103,7 @@ fun AboutSheet(
verticalAlignment = Alignment.Bottom
) {
Text(
text = "bitchat",
text = stringResource(R.string.app_name),
style = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -112,7 +113,7 @@ fun AboutSheet(
)
Text(
text = "v$versionName",
text = stringResource(R.string.version_prefix, versionName?:""),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.5f),
@@ -123,7 +124,7 @@ fun AboutSheet(
}
Text(
text = "decentralized mesh messaging with end-to-end encryption",
text = stringResource(R.string.about_tagline),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
@@ -141,7 +142,7 @@ fun AboutSheet(
) {
Icon(
imageVector = Icons.Filled.Bluetooth,
contentDescription = "Offline Mesh Chat",
contentDescription = stringResource(R.string.cd_offline_mesh_chat),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
@@ -150,14 +151,14 @@ fun AboutSheet(
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = "Offline Mesh Chat",
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 = "Communicate directly via Bluetooth LE without internet or servers. Messages relay through nearby devices to extend range.",
text = stringResource(R.string.about_offline_mesh_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
)
@@ -173,7 +174,7 @@ fun AboutSheet(
) {
Icon(
imageVector = Icons.Default.Public,
contentDescription = "Online Geohash Channels",
contentDescription = stringResource(R.string.cd_online_geohash_channels),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
@@ -182,14 +183,14 @@ fun AboutSheet(
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = "Online Geohash Channels",
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 = "Connect with people in your area using geohash-based channels. Extend the mesh using public internet relays.",
text = stringResource(R.string.about_online_geohash_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
)
@@ -205,7 +206,7 @@ fun AboutSheet(
) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = "End-to-End Encryption",
contentDescription = stringResource(R.string.cd_end_to_end_encryption),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
@@ -214,14 +215,14 @@ fun AboutSheet(
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = "End-to-End Encryption",
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 = "Private messages are encrypted. Channel messages are public.",
text = stringResource(R.string.about_e2e_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
)
@@ -232,7 +233,7 @@ fun AboutSheet(
// Appearance Section
item(key = "appearance_section") {
Text(
text = "appearance",
text = stringResource(R.string.about_appearance),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
modifier = Modifier
@@ -247,24 +248,24 @@ fun AboutSheet(
FilterChip(
selected = themePref.isSystem,
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) },
label = { Text("system", fontFamily = FontFamily.Monospace) }
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("light", fontFamily = FontFamily.Monospace) }
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("dark", fontFamily = FontFamily.Monospace) }
label = { Text(stringResource(R.string.about_dark), fontFamily = FontFamily.Monospace) }
)
}
}
// Proof of Work Section
item(key = "pow_section") {
Text(
text = "proof of work",
text = stringResource(R.string.about_pow),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
modifier = Modifier
@@ -289,7 +290,7 @@ fun AboutSheet(
FilterChip(
selected = !powEnabled,
onClick = { PoWPreferenceManager.setPowEnabled(false) },
label = { Text("pow off", fontFamily = FontFamily.Monospace) }
label = { Text(stringResource(R.string.about_pow_off), fontFamily = FontFamily.Monospace) }
)
FilterChip(
selected = powEnabled,
@@ -299,7 +300,7 @@ fun AboutSheet(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("pow on", fontFamily = FontFamily.Monospace)
Text(stringResource(R.string.about_pow_on), fontFamily = FontFamily.Monospace)
// Show current difficulty
if (powEnabled) {
Surface(
@@ -313,7 +314,7 @@ fun AboutSheet(
}
Text(
text = "add proof of work to geohash messages for spam deterrence.",
text = stringResource(R.string.about_pow_tip),
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.6f)
@@ -326,7 +327,7 @@ fun AboutSheet(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "difficulty: $powDifficulty bits (~${NostrProofOfWork.estimateMiningTime(powDifficulty)})",
text = stringResource(R.string.about_pow_difficulty, powDifficulty, NostrProofOfWork.estimateMiningTime(powDifficulty)),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
)
@@ -353,20 +354,20 @@ fun AboutSheet(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "difficulty $powDifficulty requires ~${NostrProofOfWork.estimateWork(powDifficulty)} hash attempts",
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 -> "no proof of work required"
powDifficulty <= 8 -> "very low - minimal spam protection"
powDifficulty <= 12 -> "low - basic spam protection"
powDifficulty <= 16 -> "medium - good spam protection"
powDifficulty <= 20 -> "high - strong spam protection"
powDifficulty <= 24 -> "very high - may cause delays"
else -> "extreme - significant computation required"
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,
@@ -384,7 +385,7 @@ fun AboutSheet(
val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(context)) }
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
Text(
text = "network",
text = stringResource(R.string.about_network),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
modifier = Modifier
@@ -429,7 +430,7 @@ fun AboutSheet(
)
}
Text(
text = "route internet over tor for enhanced privacy.",
text = stringResource(R.string.about_tor_route),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
@@ -446,14 +447,14 @@ fun AboutSheet(
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "tor Status: $statusText, bootstrap ${torStatus.bootstrapPercent}%",
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()) {
Text(
text = "Last: ${lastLog.take(160)}",
text = stringResource(R.string.about_last, lastLog.take(160)),
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
@@ -483,20 +484,20 @@ fun AboutSheet(
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = "Warning",
contentDescription = stringResource(R.string.cd_warning),
tint = errorColor,
modifier = Modifier.size(16.dp)
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "Emergency Data Deletion",
text = stringResource(R.string.about_emergency_title),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = errorColor
)
Text(
text = "Tip: Triple-click the app title to emergency delete all stored data including messages, keys, and settings.",
text = stringResource(R.string.about_emergency_tip),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f)
@@ -523,14 +524,14 @@ fun AboutSheet(
)
) {
Text(
text = "Debug Settings",
text = stringResource(R.string.about_debug_settings),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
}
}
Text(
text = "Open Source • Privacy First • Decentralized",
text = stringResource(R.string.about_footer),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
@@ -557,7 +558,7 @@ fun AboutSheet(
.padding(horizontal = 16.dp)
) {
Text(
text = "Close",
text = stringResource(R.string.close_plain),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onBackground
)
@@ -588,7 +589,7 @@ fun PasswordPromptDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = "Enter Channel Password",
text = stringResource(R.string.pwd_prompt_title),
style = MaterialTheme.typography.titleMedium,
color = colorScheme.onSurface
)
@@ -596,7 +597,7 @@ fun PasswordPromptDialog(
text = {
Column {
Text(
text = "Channel $channelName is password protected. Enter the password to join.",
text = stringResource(R.string.pwd_prompt_message, channelName ?: ""),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.onSurface
)
@@ -605,7 +606,7 @@ fun PasswordPromptDialog(
OutlinedTextField(
value = passwordInput,
onValueChange = onPasswordChange,
label = { Text("Password", style = MaterialTheme.typography.bodyMedium) },
label = { Text(stringResource(R.string.pwd_label), style = MaterialTheme.typography.bodyMedium) },
textStyle = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
@@ -619,7 +620,7 @@ fun PasswordPromptDialog(
confirmButton = {
TextButton(onClick = onConfirm) {
Text(
text = "Join",
text = stringResource(R.string.join),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary
)
@@ -628,7 +629,7 @@ fun PasswordPromptDialog(
dismissButton = {
TextButton(onClick = onDismiss) {
Text(
text = "Cancel",
text = stringResource(R.string.cancel),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.onSurface
)
@@ -21,11 +21,16 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import androidx.compose.ui.text.font.FontWeight
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
/**
* Header components for ChatScreen
@@ -51,23 +56,27 @@ fun isFavoriteReactive(
}
@Composable
fun TorStatusIcon(
fun TorStatusDot(
modifier: Modifier = Modifier
) {
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
val cableColor = when {
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500)
torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851)
else -> Color.Red
val dotColor = when {
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) // Orange - bootstrapping
torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) // Green - connected
else -> Color.Red // Red - error/disconnected
}
Canvas(
modifier = modifier
) {
val radius = size.minDimension / 2
drawCircle(
color = dotColor,
radius = radius,
center = Offset(size.width / 2, size.height / 2)
)
}
Icon(
imageVector = Icons.Outlined.Cable,
contentDescription = "Tor status",
modifier = modifier,
tint = cableColor
)
}
}
@@ -80,23 +89,23 @@ fun NoiseSessionIcon(
"uninitialized" -> Triple(
Icons.Outlined.NoEncryption,
Color(0x87878700), // Grey - ready to establish
"Ready for handshake"
stringResource(R.string.cd_ready_for_handshake)
)
"handshaking" -> Triple(
Icons.Outlined.Sync,
Color(0x87878700), // Grey - in progress
"Handshake in progress"
stringResource(R.string.cd_handshake_in_progress)
)
"established" -> Triple(
Icons.Filled.Lock,
Color(0xFFFF9500), // Orange - secure
"End-to-end encrypted"
stringResource(R.string.cd_encrypted)
)
else -> { // "failed" or any other state
Triple(
Icons.Outlined.Warning,
Color(0xFFFF4444), // Red - error
"Handshake failed"
stringResource(R.string.cd_handshake_failed)
)
}
}
@@ -129,7 +138,7 @@ fun NicknameEditor(
modifier = modifier
) {
Text(
text = "@",
text = stringResource(R.string.at_symbol),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary.copy(alpha = 0.8f)
)
@@ -193,8 +202,8 @@ fun PeerCounter(
Icon(
imageVector = Icons.Default.Group,
contentDescription = when (selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> "Geohash participants"
else -> "Connected peers"
is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants)
else -> stringResource(R.string.cd_connected_peers)
},
modifier = Modifier.size(16.dp),
tint = countColor
@@ -211,7 +220,7 @@ fun PeerCounter(
if (joinedChannels.isNotEmpty()) {
Text(
text = " · ⧉ ${joinedChannels.size}",
text = stringResource(R.string.channel_count_prefix) + "${joinedChannels.size}",
style = MaterialTheme.typography.bodyMedium,
color = if (isConnected) Color(0xFF00C851) else Color.Red,
fontSize = 16.sp,
@@ -231,7 +240,8 @@ fun ChatHeaderContent(
onSidebarClick: () -> Unit,
onTripleClick: () -> Unit,
onShowAppInfo: () -> Unit,
onLocationChannelsClick: () -> Unit
onLocationChannelsClick: () -> Unit,
onLocationNotesClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
@@ -287,6 +297,7 @@ fun ChatHeaderContent(
onTripleTitleClick = onTripleClick,
onSidebarClick = onSidebarClick,
onLocationChannelsClick = onLocationChannelsClick,
onLocationNotesClick = onLocationNotesClick,
viewModel = viewModel
)
}
@@ -373,13 +384,13 @@ private fun PrivateChatHeader(
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.back),
modifier = Modifier.size(16.dp),
tint = colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "back",
text = stringResource(R.string.chat_back),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary
)
@@ -405,7 +416,7 @@ private fun PrivateChatHeader(
if (showGlobe) {
Icon(
imageVector = Icons.Outlined.Public,
contentDescription = "Nostr reachable",
contentDescription = stringResource(R.string.cd_nostr_reachable),
modifier = Modifier.size(14.dp),
tint = Color(0xFF9B59B6) // Purple like iOS
)
@@ -428,7 +439,7 @@ private fun PrivateChatHeader(
) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
modifier = Modifier.size(18.dp), // Slightly larger than sidebar icon
tint = if (isFavorite) Color(0xFFFFD700) else Color(0x87878700) // Yellow or grey
)
@@ -463,13 +474,13 @@ private fun ChannelHeader(
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.back),
modifier = Modifier.size(16.dp),
tint = colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "back",
text = stringResource(R.string.chat_back),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary
)
@@ -478,7 +489,7 @@ private fun ChannelHeader(
// Title - perfectly centered regardless of other elements
Text(
text = "channel: $channel",
text = stringResource(R.string.chat_channel_prefix, channel),
style = MaterialTheme.typography.titleMedium,
color = Color(0xFFFF9500), // Orange to match input field
modifier = Modifier
@@ -492,7 +503,7 @@ private fun ChannelHeader(
modifier = Modifier.align(Alignment.CenterEnd)
) {
Text(
text = "leave",
text = stringResource(R.string.chat_leave),
style = MaterialTheme.typography.bodySmall,
color = Color.Red
)
@@ -508,6 +519,7 @@ private fun MainHeader(
onTripleTitleClick: () -> Unit,
onSidebarClick: () -> Unit,
onLocationChannelsClick: () -> Unit,
onLocationNotesClick: () -> Unit,
viewModel: ChatViewModel
) {
val colorScheme = MaterialTheme.colorScheme
@@ -534,7 +546,7 @@ private fun MainHeader(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "bitchat/",
text = stringResource(R.string.app_brand),
style = MaterialTheme.typography.headlineSmall,
color = colorScheme.primary,
modifier = Modifier.singleOrTripleClickable(
@@ -562,7 +574,7 @@ private fun MainHeader(
// Render icon directly to avoid symbol resolution issues
Icon(
imageVector = Icons.Filled.Email,
contentDescription = "Unread private messages",
contentDescription = stringResource(R.string.cd_unread_private_messages),
modifier = Modifier
.size(16.dp)
.clickable { viewModel.openLatestUnreadPrivateChat() },
@@ -571,7 +583,7 @@ private fun MainHeader(
}
// Location channels button (matching iOS implementation) and bookmark grouped tightly
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 4.dp)) {
LocationChannelsButton(
viewModel = viewModel,
onClick = onLocationChannelsClick
@@ -586,14 +598,14 @@ private fun MainHeader(
val isBookmarked = bookmarks.contains(currentGeohash)
Box(
modifier = Modifier
.padding(start = 1.dp) // minimal gap between geohash and bookmark
.padding(start = 2.dp) // minimal gap between geohash and bookmark
.size(20.dp)
.clickable { bookmarksStore.toggle(currentGeohash) },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
contentDescription = "Toggle bookmark",
contentDescription = stringResource(R.string.cd_toggle_bookmark),
tint = if (isBookmarked) Color(0xFF00C851) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
modifier = Modifier.size(16.dp)
)
@@ -601,15 +613,25 @@ private fun MainHeader(
}
}
// Tor status cable icon when Tor is enabled
TorStatusIcon(modifier = Modifier.size(14.dp))
// Location Notes button (extracted to separate component)
LocationNotesButton(
viewModel = viewModel,
onClick = onLocationNotesClick
)
// Tor status dot when Tor is enabled
TorStatusDot(
modifier = Modifier
.size(8.dp)
.padding(start = 0.dp, end = 2.dp)
)
// PoW status indicator
PoWStatusIndicator(
modifier = Modifier,
style = PoWIndicatorStyle.COMPACT
)
Spacer(modifier = Modifier.width(2.dp))
PeerCounter(
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
joinedChannels = joinedChannels,
@@ -668,7 +690,7 @@ private fun LocationChannelsButton(
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Default.PinDrop,
contentDescription = "Teleported",
contentDescription = stringResource(R.string.cd_teleported),
modifier = Modifier.size(12.dp),
tint = badgeColor
)
@@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButton
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
@@ -62,6 +63,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
var showPasswordDialog by remember { mutableStateOf(false) }
var passwordInput by remember { mutableStateOf("") }
var showLocationChannelsSheet by remember { mutableStateOf(false) }
var showLocationNotesSheet by remember { mutableStateOf(false) }
var showUserSheet by remember { mutableStateOf(false) }
var selectedUserForSheet by remember { mutableStateOf("") }
var selectedMessageForSheet by remember { mutableStateOf<BitchatMessage?>(null) }
@@ -97,6 +99,13 @@ fun ChatScreen(viewModel: ChatViewModel) {
}
}
// Determine whether to show media buttons (only hide in geohash location chats)
val showMediaButtons = when {
selectedPrivatePeer != null -> true
currentChannel != null -> true
else -> selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location
}
// Use WindowInsets to handle keyboard properly
Box(
modifier = Modifier
@@ -225,7 +234,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
colorScheme = colorScheme
colorScheme = colorScheme,
showMediaButtons = showMediaButtons
)
}
@@ -240,7 +250,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
onSidebarToggle = { viewModel.showSidebar() },
onShowAppInfo = { viewModel.showAppInfo() },
onPanicClear = { viewModel.panicClearAllData() },
onLocationChannelsClick = { showLocationChannelsSheet = true }
onLocationChannelsClick = { showLocationChannelsSheet = true },
onLocationNotesClick = { showLocationNotesSheet = true }
)
// Divider under header - positioned after status bar + header height
@@ -294,7 +305,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) {
Icon(
imageVector = Icons.Filled.ArrowDownward,
contentDescription = "Scroll to bottom",
contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom),
tint = Color(0xFF00C851)
)
}
@@ -353,6 +364,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
onAppInfoDismiss = { viewModel.hideAppInfo() },
showLocationChannelsSheet = showLocationChannelsSheet,
onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false },
showLocationNotesSheet = showLocationNotesSheet,
onLocationNotesSheetDismiss = { showLocationNotesSheet = false },
showUserSheet = showUserSheet,
onUserSheetDismiss = {
showUserSheet = false
@@ -381,7 +394,8 @@ private fun ChatInputSection(
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
colorScheme: ColorScheme
colorScheme: ColorScheme,
showMediaButtons: Boolean
) {
Surface(
modifier = Modifier.fillMaxWidth(),
@@ -417,6 +431,7 @@ private fun ChatInputSection(
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
showMediaButtons = showMediaButtons,
modifier = Modifier.fillMaxWidth()
)
}
@@ -434,8 +449,12 @@ private fun ChatFloatingHeader(
onSidebarToggle: () -> Unit,
onShowAppInfo: () -> Unit,
onPanicClear: () -> Unit,
onLocationChannelsClick: () -> Unit
onLocationChannelsClick: () -> Unit,
onLocationNotesClick: () -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) }
Surface(
modifier = Modifier
.fillMaxWidth()
@@ -459,7 +478,12 @@ private fun ChatFloatingHeader(
onSidebarClick = onSidebarToggle,
onTripleClick = onPanicClear,
onShowAppInfo = onShowAppInfo,
onLocationChannelsClick = onLocationChannelsClick
onLocationChannelsClick = onLocationChannelsClick,
onLocationNotesClick = {
// Ensure location is loaded before showing sheet
locationManager.refreshChannels()
onLocationNotesClick()
}
)
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -470,6 +494,7 @@ private fun ChatFloatingHeader(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChatDialogs(
showPasswordDialog: Boolean,
@@ -482,6 +507,8 @@ private fun ChatDialogs(
onAppInfoDismiss: () -> Unit,
showLocationChannelsSheet: Boolean,
onLocationChannelsSheetDismiss: () -> Unit,
showLocationNotesSheet: Boolean,
onLocationNotesSheetDismiss: () -> Unit,
showUserSheet: Boolean,
onUserSheetDismiss: () -> Unit,
selectedUserForSheet: String,
@@ -522,6 +549,14 @@ private fun ChatDialogs(
)
}
// Location notes sheet (extracted to separate presenter)
if (showLocationNotesSheet) {
LocationNotesSheetPresenter(
viewModel = viewModel,
onDismiss = onLocationNotesSheetDismiss
)
}
// User action sheet
if (showUserSheet) {
ChatUserSheet(
@@ -3,10 +3,7 @@ package com.bitchat.android.ui
/**
* UI constants/utilities for nickname rendering.
*/
const val MAX_NICKNAME_LENGTH: Int = 15
fun truncateNickname(name: String, maxLen: Int = MAX_NICKNAME_LENGTH): String {
fun truncateNickname(name: String, maxLen: Int = com.bitchat.android.util.AppConstants.UI.MAX_NICKNAME_LENGTH): String {
return if (name.length <= maxLen) name else name.take(maxLen)
}
@@ -12,6 +12,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import kotlinx.coroutines.launch
@@ -61,7 +63,7 @@ fun ChatUserSheet(
) {
// Header
Text(
text = "@$targetNickname",
text = stringResource(R.string.at_nickname, targetNickname),
fontSize = 18.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -69,7 +71,7 @@ fun ChatUserSheet(
)
Text(
text = if (selectedMessage != null) "choose an action for this message or user" else "choose an action for this user",
text = if (selectedMessage != null) stringResource(R.string.choose_action_message_or_user) else stringResource(R.string.choose_action_user),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
@@ -83,8 +85,8 @@ fun ChatUserSheet(
selectedMessage?.let { message ->
item {
UserActionRow(
title = "copy message",
subtitle = "copy this message to clipboard",
title = stringResource(R.string.action_copy_message_title),
subtitle = stringResource(R.string.action_copy_message_subtitle),
titleColor = standardGrey,
onClick = {
// Copy the message content to clipboard
@@ -100,8 +102,8 @@ fun ChatUserSheet(
// Slap action
item {
UserActionRow(
title = "slap $targetNickname",
subtitle = "send a playful slap message",
title = stringResource(R.string.action_slap_title, targetNickname),
subtitle = stringResource(R.string.action_slap_subtitle),
titleColor = standardBlue,
onClick = {
// Send slap command
@@ -114,8 +116,8 @@ fun ChatUserSheet(
// Hug action
item {
UserActionRow(
title = "hug $targetNickname",
subtitle = "send a friendly hug message",
title = stringResource(R.string.action_hug_title, targetNickname),
subtitle = stringResource(R.string.action_hug_subtitle),
titleColor = standardGreen,
onClick = {
// Send hug command
@@ -128,8 +130,8 @@ fun ChatUserSheet(
// Block action
item {
UserActionRow(
title = "block $targetNickname",
subtitle = "block all messages from this user",
title = stringResource(R.string.action_block_title, targetNickname),
subtitle = stringResource(R.string.action_block_subtitle),
titleColor = standardRed,
onClick = {
// Check if we're in a geohash channel
@@ -158,7 +160,7 @@ fun ChatUserSheet(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "cancel",
text = stringResource(R.string.cancel_lower),
fontSize = BASE_FONT_SIZE.sp,
fontFamily = FontFamily.Monospace
)
@@ -147,21 +147,13 @@ class ChatViewModel(
mediaSendingManager.handleTransferProgressEvent(evt)
}
}
// Removed background location notes subscription. Notes now load only when sheet opens.
}
fun cancelMediaSend(messageId: String) {
val transferId = synchronized(transferMessageMap) { messageTransferMap[messageId] }
if (transferId != null) {
val cancelled = meshService.cancelFileTransfer(transferId)
if (cancelled) {
// Remove the message from chat upon explicit cancel
messageManager.removeMessageById(messageId)
synchronized(transferMessageMap) {
transferMessageMap.remove(transferId)
messageTransferMap.remove(messageId)
}
}
}
// Delegate to MediaSendingManager which tracks transfer IDs and cleans up UI state
mediaSendingManager.cancelMediaSend(messageId)
}
private fun loadAndInitialize() {
@@ -226,20 +218,6 @@ class ChatViewModel(
} catch (_: Exception) { }
// Note: Mesh service is now started by MainActivity
// Show welcome message if no peers after delay
viewModelScope.launch {
delay(10000)
if (state.getConnectedPeersValue().isEmpty() && state.getMessagesValue().isEmpty()) {
val welcomeMessage = BitchatMessage(
sender = "system",
content = "get people around you to download bitchat and chat with them here!",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(welcomeMessage)
}
}
// BLE receives are inserted by MessageHandler path; no VoiceNoteBus for Tor in this branch.
}
@@ -605,6 +583,8 @@ class ChatViewModel(
}
}
// Location notes subscription management moved to LocationNotesViewModelExtensions.kt
/**
* Update reactive states for all connected peers (session states, fingerprints, nicknames, RSSI)
*/
@@ -20,6 +20,8 @@ import androidx.compose.ui.unit.sp
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 com.bitchat.android.R
/**
* GeohashPeopleList - iOS-compatible component for displaying geohash participants
@@ -66,7 +68,7 @@ fun GeohashPeopleList(
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "PEOPLE",
text = stringResource(R.string.geohash_people_header),
style = MaterialTheme.typography.labelSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
@@ -78,7 +80,7 @@ fun GeohashPeopleList(
if (geohashPeople.isEmpty()) {
// Empty state - matches iOS "nobody around..."
Text(
text = "nobody around...",
text = stringResource(R.string.nobody_around),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp
@@ -182,7 +184,7 @@ private fun GeohashPersonItem(
// Unread DM indicator (orange envelope)
Icon(
imageVector = Icons.Filled.Email,
contentDescription = "Unread message",
contentDescription = stringResource(R.string.cd_unread_message),
modifier = Modifier.size(12.dp),
tint = Color(0xFFFF9500) // iOS orange
)
@@ -253,7 +255,7 @@ private fun GeohashPersonItem(
// "You" indicator for current user
if (isMe) {
Text(
text = " (you)",
text = stringResource(R.string.you_suffix),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp
@@ -11,7 +11,6 @@ import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -37,13 +36,15 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import androidx.core.view.updateLayoutParams
import com.bitchat.android.geohash.Geohash
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
@OptIn(ExperimentalMaterial3Api::class)
class GeohashPickerActivity : ComponentActivity() {
class GeohashPickerActivity : OrientationAwareActivity() {
companion object {
const val EXTRA_INITIAL_GEOHASH = "initial_geohash"
@@ -175,7 +176,7 @@ class GeohashPickerActivity : ComponentActivity() {
shadowElevation = 6.dp
) {
Text(
text = "pan and zoom to select a geohash",
text = stringResource(R.string.pan_zoom_instruction),
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFamily = FontFamily.Monospace,
@@ -228,7 +229,7 @@ class GeohashPickerActivity : ComponentActivity() {
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Remove, contentDescription = "Decrease precision")
Icon(Icons.Filled.Remove, contentDescription = stringResource(R.string.cd_decrease_precision))
}
}
@@ -244,7 +245,7 @@ class GeohashPickerActivity : ComponentActivity() {
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Add, contentDescription = "Increase precision")
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.cd_increase_precision))
}
}
@@ -264,10 +265,10 @@ class GeohashPickerActivity : ComponentActivity() {
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Check, contentDescription = "Select geohash")
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.cd_select_geohash))
Spacer(Modifier.width(6.dp))
Text(
text = "select",
text = stringResource(R.string.select),
fontSize = (BASE_FONT_SIZE - 2).sp,
fontFamily = FontFamily.Monospace
)
@@ -170,6 +170,7 @@ fun MessageInput(
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
showMediaButtons: Boolean,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
@@ -217,7 +218,7 @@ fun MessageInput(
// Show placeholder when there's no text and not recording
if (value.text.isEmpty() && !isRecording) {
Text(
text = "type a message...",
text = stringResource(R.string.type_a_message_placeholder),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
@@ -252,8 +253,8 @@ fun MessageInput(
Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing
// Voice and image buttons when no text (always visible for mesh + channels + private)
if (value.text.isEmpty()) {
// Voice and image buttons when no text (only visible in Mesh chat)
if (value.text.isEmpty() && showMediaButtons) {
// Hold-to-record microphone
val bg = if (colorScheme.background == Color.Black) Color(0xFF00FF00).copy(alpha = 0.75f) else Color(0xFF008000).copy(alpha = 0.75f)
@@ -486,7 +487,7 @@ fun MentionSuggestionItem(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "@$suggestion",
text = stringResource(R.string.mention_suggestion_at, suggestion),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold
@@ -498,7 +499,7 @@ fun MentionSuggestionItem(
Spacer(modifier = Modifier.weight(1f))
Text(
text = "mention",
text = stringResource(R.string.mention),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
@@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -94,7 +95,7 @@ fun LinkPreviewPill(
) {
Icon(
imageVector = Icons.Outlined.Link,
contentDescription = "Link",
contentDescription = stringResource(com.bitchat.android.R.string.cd_link),
modifier = Modifier.size(24.dp),
tint = Color.Blue
)
@@ -37,6 +37,8 @@ import com.bitchat.android.geohash.GeohashChannelLevel
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 com.bitchat.android.R
/**
* Location Channels Sheet for selecting geohash-based location channels
@@ -133,7 +135,7 @@ fun LocationChannelsSheet(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "#location channels",
text = stringResource(R.string.location_channels_title),
style = MaterialTheme.typography.headlineSmall,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -141,7 +143,7 @@ fun LocationChannelsSheet(
)
Text(
text = "chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. do not screenshot or share this screen to protect your privacy.",
text = stringResource(R.string.location_channels_desc),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
@@ -170,7 +172,7 @@ fun LocationChannelsSheet(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "grant location permission",
text = stringResource(R.string.grant_location_permission),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
@@ -180,7 +182,7 @@ fun LocationChannelsSheet(
LocationChannelManager.PermissionState.RESTRICTED -> {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "location permission denied. enable in settings to use location channels.",
text = stringResource(R.string.location_permission_denied),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = Color.Red.copy(alpha = 0.8f)
@@ -194,7 +196,7 @@ fun LocationChannelsSheet(
}
) {
Text(
text = "open settings",
text = stringResource(R.string.open_settings),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
@@ -203,7 +205,7 @@ fun LocationChannelsSheet(
}
LocationChannelManager.PermissionState.AUTHORIZED -> {
Text(
text = "✓ location permission granted",
text = stringResource(R.string.location_permission_granted),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = standardGreen
@@ -216,7 +218,7 @@ fun LocationChannelsSheet(
) {
CircularProgressIndicator(modifier = Modifier.size(12.dp))
Text(
text = "checking permissions...",
text = stringResource(R.string.checking_permissions),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
@@ -232,7 +234,7 @@ fun LocationChannelsSheet(
item(key = "mesh") {
ChannelRow(
title = meshTitleWithCount(viewModel),
subtitle = "#bluetooth • ${bluetoothRangeString()}",
subtitle = stringResource(R.string.location_bluetooth_subtitle, bluetoothRangeString()),
isSelected = selectedChannel is ChannelID.Mesh,
titleColor = standardBlue,
titleBold = meshCount(viewModel) > 0,
@@ -245,8 +247,11 @@ fun LocationChannelsSheet(
}
// Nearby options (only show if location services are enabled)
// CRITICAL: Filter out .building level (precision 8) - iOS pattern
// iOS: let nearby = manager.availableChannels.filter { $0.level != .building }
if (availableChannels.isNotEmpty() && locationServicesEnabled) {
items(availableChannels) { channel ->
val nearbyChannels = availableChannels.filter { it.level != GeohashChannelLevel.BUILDING }
items(nearbyChannels) { channel ->
val coverage = coverageString(channel.geohash.length)
val nameBase = locationNames[channel.level]
val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
@@ -262,13 +267,13 @@ fun LocationChannelsSheet(
titleColor = standardGreen,
titleBold = highlight,
trailingContent = {
IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) {
Icon(
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
contentDescription = if (isBookmarked) "Unbookmark" else "Bookmark",
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
)
}
IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) {
Icon(
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
contentDescription = if (isBookmarked) stringResource(R.string.cd_remove_bookmark) else stringResource(R.string.cd_add_bookmark),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
)
}
},
onClick = {
// Selecting a suggested nearby channel is not a teleport
@@ -286,7 +291,7 @@ fun LocationChannelsSheet(
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Text(
text = "finding nearby channels…",
text = stringResource(R.string.finding_nearby_channels),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
@@ -298,7 +303,7 @@ fun LocationChannelsSheet(
if (bookmarks.isNotEmpty()) {
item(key = "bookmarked_header") {
Text(
text = "bookmarked",
text = stringResource(R.string.bookmarked),
style = MaterialTheme.typography.labelLarge,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
@@ -328,7 +333,7 @@ fun LocationChannelsSheet(
IconButton(onClick = { bookmarksStore.toggle(gh) }) {
Icon(
imageVector = Icons.Filled.Bookmark,
contentDescription = "Remove bookmark",
contentDescription = stringResource(R.string.cd_remove_bookmark),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
)
}
@@ -366,7 +371,7 @@ fun LocationChannelsSheet(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "#",
text = stringResource(R.string.hash_symbol),
fontSize = BASE_FONT_SIZE.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
@@ -409,7 +414,7 @@ fun LocationChannelsSheet(
decorationBox = { innerTextField ->
if (customGeohash.isEmpty()) {
Text(
text = "geohash",
text = stringResource(R.string.geohash_placeholder),
fontSize = BASE_FONT_SIZE.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
@@ -435,7 +440,7 @@ fun LocationChannelsSheet(
}) {
Icon(
imageVector = Icons.Filled.Map,
contentDescription = "Open map",
contentDescription = stringResource(R.string.cd_open_map),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
)
}
@@ -453,7 +458,7 @@ fun LocationChannelsSheet(
locationManager.select(ChannelID.Location(channel))
onDismiss()
} else {
customError = "invalid geohash"
customError = context.getString(R.string.invalid_geohash)
}
},
enabled = isValid,
@@ -467,14 +472,13 @@ fun LocationChannelsSheet(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "teleport",
text = stringResource(R.string.teleport),
fontSize = BASE_FONT_SIZE.sp,
fontFamily = FontFamily.Monospace
)
// iOS has a face.dashed icon, use closest Material equivalent
Icon(
imageVector = Icons.Filled.PinDrop,
contentDescription = "Teleport",
contentDescription = stringResource(R.string.cd_teleport),
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurface
)
@@ -530,11 +534,7 @@ fun LocationChannelsSheet(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (locationServicesEnabled) {
"disable location services"
} else {
"enable location services"
},
text = if (locationServicesEnabled) stringResource(R.string.disable_location_services) else stringResource(R.string.enable_location_services),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
@@ -558,7 +558,7 @@ fun LocationChannelsSheet(
.padding(horizontal = 16.dp)
) {
Text(
text = "Close",
text = stringResource(R.string.close_plain),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onBackground
)
@@ -573,8 +573,6 @@ fun LocationChannelsSheet(
if (isPresented) {
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
locationManager.refreshChannels()
}
if (locationServicesEnabled) {
locationManager.beginLiveRefresh()
}
val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
@@ -668,7 +666,7 @@ private fun ChannelRow(
if (isSelected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Selected",
contentDescription = stringResource(R.string.cd_selected),
tint = Color(0xFF32D74B), // iOS green for checkmark
modifier = Modifier.size(20.dp)
)
@@ -695,10 +693,13 @@ private fun splitTitleAndCount(title: String): Pair<String, String?> {
}
}
@Composable
private fun meshTitleWithCount(viewModel: ChatViewModel): String {
val meshCount = meshCount(viewModel)
val noun = if (meshCount == 1) "person" else "people"
return "mesh [$meshCount $noun]"
val ctx = androidx.compose.ui.platform.LocalContext.current
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, meshCount, meshCount)
val meshLabel = stringResource(com.bitchat.android.R.string.mesh_label)
return "$meshLabel [$peopleText]"
}
private fun meshCount(viewModel: ChatViewModel): Int {
@@ -708,14 +709,26 @@ private fun meshCount(viewModel: ChatViewModel): Int {
} ?: 0
}
@Composable
private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String {
val noun = if (participantCount == 1) "person" else "people"
return "${channel.level.displayName.lowercase()} [$participantCount $noun]"
val ctx = androidx.compose.ui.platform.LocalContext.current
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
val levelName = when (channel.level) {
com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes
com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block)
com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD -> stringResource(com.bitchat.android.R.string.location_level_neighborhood)
com.bitchat.android.geohash.GeohashChannelLevel.CITY -> stringResource(com.bitchat.android.R.string.location_level_city)
com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE -> stringResource(com.bitchat.android.R.string.location_level_province)
com.bitchat.android.geohash.GeohashChannelLevel.REGION -> stringResource(com.bitchat.android.R.string.location_level_region)
}
return "$levelName [$peopleText]"
}
@Composable
private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String {
val noun = if (participantCount == 1) "person" else "people"
return "#$geohash [$participantCount $noun]"
val ctx = androidx.compose.ui.platform.LocalContext.current
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
return "#$geohash [$peopleText]"
}
private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean {
@@ -738,7 +751,8 @@ private fun levelForLength(length: Int): GeohashChannelLevel {
5 -> GeohashChannelLevel.CITY
6 -> GeohashChannelLevel.NEIGHBORHOOD
7 -> GeohashChannelLevel.BLOCK
else -> GeohashChannelLevel.BLOCK
8 -> GeohashChannelLevel.BUILDING // iOS: precision 8 for building-level
else -> if (length > 8) GeohashChannelLevel.BUILDING else GeohashChannelLevel.BLOCK
}
}
@@ -0,0 +1,67 @@
package com.bitchat.android.ui
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.bitchat.android.R
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager
/**
* Location Notes button component for MainHeader
* Shows in mesh mode when location permission granted AND services enabled
* Icon turns primary color when notes exist, gray otherwise
*/
@Composable
fun LocationNotesButton(
viewModel: ChatViewModel,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val context = LocalContext.current
// Get channel and permission state
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val locationManager = remember { LocationChannelManager.getInstance(context) }
val permissionState by locationManager.permissionState.observeAsState()
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
// Check both permission AND location services enabled
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
val locationEnabled = locationPermissionGranted && locationServicesEnabled
// Get notes count from LocationNotesManager
val notesManager = remember { LocationNotesManager.getInstance() }
val notes by notesManager.notes.observeAsState(emptyList())
val notesCount = notes.size
// Only show in mesh mode when location is authorized (iOS pattern)
if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) {
val hasNotes = notesCount > 0
IconButton(
onClick = onClick,
modifier = modifier.size(24.dp)
) {
Icon(
imageVector = Icons.Outlined.Description, // "long.text.page.and.pencil" equivalent
contentDescription = stringResource(R.string.cd_location_notes),
modifier = Modifier.size(16.dp),
tint = if (hasNotes) colorScheme.primary else Color.Gray
)
}
}
}
@@ -0,0 +1,612 @@
package com.bitchat.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
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
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.pluralStringResource
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 com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager
import java.text.SimpleDateFormat
import java.util.*
import java.util.Calendar
/**
* Location Notes Sheet - EXACT iOS UI match for bitchat
* Matches iOS LocationNotesView.swift exactly in style, colors, fonts, and text
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocationNotesSheet(
geohash: String,
locationName: String?,
nickname: String?,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val isDark = isSystemInDarkTheme()
// iOS color scheme
val backgroundColor = if (isDark) Color.Black else Color.White
val accentGreen = if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0)
// Managers
val notesManager = remember { LocationNotesManager.getInstance() }
val locationManager = remember { LocationChannelManager.getInstance(context) }
// State
val notes by notesManager.notes.observeAsState(emptyList())
val state by notesManager.state.observeAsState(LocationNotesManager.State.IDLE)
val errorMessage by notesManager.errorMessage.observeAsState()
val initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false)
// 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 displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() }
?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() }
// Input field state
var draft by remember { mutableStateOf("") }
val sendButtonEnabled = draft.trim().isNotEmpty() && state != LocationNotesManager.State.NO_RELAYS
// Scroll state
val listState = rememberLazyListState()
// Effect to set geohash when sheet opens
LaunchedEffect(geohash) {
notesManager.setGeohash(geohash)
}
// Cleanup when sheet closes
DisposableEffect(Unit) {
onDispose {
notesManager.cancel()
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
modifier = modifier,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = backgroundColor,
contentColor = if (isDark) Color.White else Color.Black
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.9f)
) {
// Header section (matches iOS headerSection)
LocationNotesHeader(
geohash = geohash,
count = count,
locationName = displayLocationName,
state = state,
accentGreen = accentGreen,
backgroundColor = backgroundColor,
onClose = onDismiss
)
// ScrollView with notes content
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(backgroundColor)
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
// Notes content (matches iOS notesContent)
when {
state == LocationNotesManager.State.NO_RELAYS -> {
item {
NoRelaysRow(
onRetry = { notesManager.refresh() }
)
}
}
state == LocationNotesManager.State.LOADING && !initialLoadComplete -> {
item {
LoadingRow()
}
}
notes.isEmpty() -> {
item {
EmptyRow()
}
}
else -> {
items(notes, key = { it.id }) { note ->
NoteRow(note = note)
Spacer(modifier = Modifier.height(12.dp))
}
}
}
// Error row (matches iOS errorRow)
errorMessage?.let { error ->
if (state != LocationNotesManager.State.NO_RELAYS) {
item {
ErrorRow(
message = error,
onDismiss = { notesManager.clearError() }
)
}
}
}
}
}
// Divider before input (matches iOS overlay)
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),
thickness = 1.dp
)
// Input section (matches iOS inputSection)
LocationNotesInputSection(
draft = draft,
onDraftChange = { draft = it },
sendButtonEnabled = sendButtonEnabled,
accentGreen = accentGreen,
backgroundColor = backgroundColor,
onSend = {
val content = draft.trim()
if (content.isNotEmpty()) {
notesManager.send(content, nickname)
draft = ""
}
}
)
}
}
}
/**
* Header section - matches iOS headerSection exactly
* Shows: "#geohash • X notes", location name, description, and close button
*/
@Composable
private fun LocationNotesHeader(
geohash: String,
count: Int,
locationName: String?,
state: LocationNotesManager.State,
accentGreen: Color,
backgroundColor: Color,
onClose: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.padding(horizontal = 16.dp)
.padding(top = 16.dp, bottom = 12.dp)
) {
// Title row with close button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Localized title with ±1 and note count
Text(
text = pluralStringResource(
id = R.plurals.location_notes_title,
count = count,
geohash,
count
),
fontFamily = FontFamily.Monospace,
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface
)
// Close button - iOS style with xmark icon
Box(
modifier = Modifier
.size(32.dp)
.clickable(onClick = onClose),
contentAlignment = Alignment.Center
) {
Text(
text = "",
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Location name in green (building or block)
locationName?.let { name ->
if (name.isNotEmpty()) {
Text(
text = name,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = accentGreen
)
Spacer(modifier = Modifier.height(8.dp))
}
}
// Description
Text(
text = stringResource(R.string.location_notes_description),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
// Relays paused message if no relays
if (state == LocationNotesManager.State.NO_RELAYS) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.location_notes_relays_unavailable),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
/**
* Note row - matches iOS noteRow exactly
* Shows @basename then timestamp, then content below
*/
@Composable
private fun NoteRow(note: LocationNotesManager.Note) {
// Extract baseName (before #suffix like iOS)
val baseName = note.displayName.split("#", limit = 2).firstOrNull() ?: note.displayName
val ts = timestampText(note.createdAt)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
// First row: @nickname and timestamp
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "@$baseName",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
if (ts.isNotEmpty()) {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = ts,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
Spacer(modifier = Modifier.height(2.dp))
// Second row: content
Text(
text = note.content,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
/**
* No relays row - matches iOS noRelaysRow
*/
@Composable
private fun NoRelaysRow(onRetry: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
) {
Text(
text = stringResource(R.string.location_notes_no_relays_title),
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.location_notes_no_relays_desc),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.retry),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onRetry)
)
}
}
/**
* Loading row - matches iOS loadingRow
*/
@Composable
private fun LoadingRow() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = stringResource(R.string.loading_location_notes),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
/**
* Empty row - matches iOS emptyRow
*/
@Composable
private fun EmptyRow() {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
) {
Text(
text = stringResource(R.string.location_notes_empty_title),
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.location_notes_empty_desc),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
/**
* Error row - matches iOS errorRow
*/
@Composable
private fun ErrorRow(message: String, onDismiss: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "",
fontSize = 12.sp
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = message,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.dismiss),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onDismiss)
)
}
}
/**
* Input section - matches main chat input exactly
*/
@Composable
private fun LocationNotesInputSection(
draft: String,
onDraftChange: (String) -> Unit,
sendButtonEnabled: Boolean,
accentGreen: Color,
backgroundColor: Color,
onSend: () -> Unit
) {
val isDark = isSystemInDarkTheme()
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.padding(horizontal = 12.dp, vertical = 8.dp), // Match main chat padding
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) // Match main chat spacing
) {
// Text input with placeholder overlay (matches main chat exactly)
Box(
modifier = Modifier.weight(1f)
) {
androidx.compose.foundation.text.BasicTextField(
value = draft,
onValueChange = onDraftChange,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.primary,
fontFamily = FontFamily.Monospace
),
cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
imeAction = androidx.compose.ui.text.input.ImeAction.Send
),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
onSend = { if (sendButtonEnabled) onSend() }
),
modifier = Modifier.fillMaxWidth()
)
// Placeholder when empty (matches main chat)
if (draft.isEmpty()) {
Text(
text = stringResource(R.string.location_notes_input_placeholder),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth()
)
}
}
// Send button - circular with icon (matches main chat exactly)
IconButton(
onClick = { if (sendButtonEnabled) onSend() },
enabled = sendButtonEnabled,
modifier = Modifier.size(32.dp)
) {
Box(
modifier = Modifier
.size(30.dp)
.background(
color = if (!sendButtonEnabled) {
colorScheme.onSurface.copy(alpha = 0.3f)
} else {
accentGreen.copy(alpha = 0.75f)
},
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.ArrowUpward,
contentDescription = stringResource(R.string.send_message),
modifier = Modifier.size(20.dp),
tint = if (!sendButtonEnabled) {
colorScheme.onSurface.copy(alpha = 0.5f)
} else if (isDark) {
Color.Black // Black arrow on green in dark theme
} else {
Color.White // White arrow on green in light theme
}
)
}
}
}
}
/**
* Timestamp text - matches iOS timestampText exactly
* Shows relative time for < 7 days, absolute date otherwise
*/
private fun timestampText(createdAt: Int): String {
val date = Date(createdAt * 1000L)
val now = Date()
// Calculate days difference
val calendar = Calendar.getInstance()
calendar.time = date
val dateDay = calendar.get(Calendar.DAY_OF_YEAR)
val dateYear = calendar.get(Calendar.YEAR)
calendar.time = now
val nowDay = calendar.get(Calendar.DAY_OF_YEAR)
val nowYear = calendar.get(Calendar.YEAR)
val daysDiff = if (dateYear == nowYear) {
nowDay - dateDay
} else {
// Simplified: just check if less than 7 days by timestamp
val diff = (now.time - date.time) / (1000 * 60 * 60 * 24)
diff.toInt()
}
return if (daysDiff < 7) {
// Relative formatting (abbreviated)
val diffMillis = now.time - date.time
val diffSeconds = diffMillis / 1000
when {
diffSeconds < 60 -> "" // Don't show "just now" in iOS
diffSeconds < 3600 -> {
val minutes = (diffSeconds / 60).toInt()
"${minutes}m ago"
}
diffSeconds < 86400 -> {
val hours = (diffSeconds / 3600).toInt()
"${hours}h ago"
}
else -> {
val days = (diffSeconds / 86400).toInt()
"${days}d ago"
}
}
} else {
// Absolute date formatting
val sameYear = dateYear == nowYear
val formatter = if (sameYear) {
SimpleDateFormat("MMM d", Locale.getDefault())
} else {
SimpleDateFormat("MMM d, y", Locale.getDefault())
}
formatter.format(date)
}
}
@@ -0,0 +1,100 @@
package com.bitchat.android.ui
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 com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
/**
* Presenter component for LocationNotesSheet
* Handles sheet presentation logic with proper error states
* Extracts this logic from ChatScreen for better separation of concerns
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocationNotesSheetPresenter(
viewModel: ChatViewModel,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val locationManager = remember { LocationChannelManager.getInstance(context) }
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
val nickname by viewModel.nickname.observeAsState("")
// 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 locationName = locationNames[GeohashChannelLevel.BUILDING]
?: locationNames[GeohashChannelLevel.BLOCK]
LocationNotesSheet(
geohash = buildingGeohash,
locationName = locationName,
nickname = nickname,
onDismiss = onDismiss
)
} else {
// No building geohash available - show error state (matches iOS)
LocationNotesErrorSheet(
onDismiss = onDismiss,
locationManager = locationManager
)
}
}
/**
* Error sheet when location is unavailable
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LocationNotesErrorSheet(
onDismiss: () -> Unit,
locationManager: LocationChannelManager
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Location Unavailable",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Location permission is required for notes",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = {
// UNIFIED FIX: Enable location services first (user toggle)
locationManager.enableLocationServices()
// Then request location channels (which will also request permission if needed)
locationManager.enableLocationChannels()
locationManager.refreshChannels()
}) {
Text("Enable Location")
}
}
}
}
@@ -20,7 +20,7 @@ class MediaSendingManager(
) {
companion object {
private const val TAG = "MediaSendingManager"
private const val MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB limit
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit
}
// Track in-flight transfer progress: transferId -> messageId and reverse
@@ -275,6 +275,9 @@ class MediaSendingManager(
if (transferId != null) {
val cancelled = meshService.cancelFileTransfer(transferId)
if (cancelled) {
// Try to remove cached local file for this message (if any)
runCatching { findMessagePathById(messageId)?.let { java.io.File(it).delete() } }
// Remove the message from chat upon explicit cancel
messageManager.removeMessageById(messageId)
synchronized(transferMessageMap) {
@@ -285,6 +288,20 @@ class MediaSendingManager(
}
}
private fun findMessagePathById(messageId: String): String? {
// Search main timeline
state.getMessagesValue().firstOrNull { it.id == messageId }?.content?.let { return it }
// Search private chats
state.getPrivateChatsValue().values.forEach { list ->
list.firstOrNull { it.id == messageId }?.content?.let { return it }
}
// Search channel messages
state.getChannelMessagesValue().values.forEach { list ->
list.firstOrNull { it.id == messageId }?.content?.let { return it }
}
return null
}
/**
* Update progress for a transfer
*/
@@ -43,6 +43,9 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.CircleShape
import com.bitchat.android.ui.media.FileMessageItem
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.R
import androidx.compose.ui.res.stringResource
// VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer
@@ -334,11 +337,11 @@ fun MessageItem(
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = "Cancel", tint = Color.White, modifier = Modifier.size(14.dp))
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp))
}
}
} else {
Text(text = "[file unavailable]", fontFamily = FontFamily.Monospace, color = Color.Gray)
Text(text = stringResource(R.string.file_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray)
}
}
}
@@ -471,7 +474,7 @@ fun DeliveryStatusIcon(status: DeliveryStatus) {
when (status) {
is DeliveryStatus.Sending -> {
Text(
text = "",
text = stringResource(R.string.status_sending),
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.6f)
)
@@ -479,7 +482,7 @@ fun DeliveryStatusIcon(status: DeliveryStatus) {
is DeliveryStatus.Sent -> {
// Use a subtle hollow marker for Sent; single check is reserved for Delivered (iOS parity)
Text(
text = "",
text = stringResource(R.string.status_pending),
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.6f)
)
@@ -487,14 +490,14 @@ fun DeliveryStatusIcon(status: DeliveryStatus) {
is DeliveryStatus.Delivered -> {
// Single check for Delivered (matches iOS expectations)
Text(
text = "",
text = stringResource(R.string.status_sent),
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.8f)
)
}
is DeliveryStatus.Read -> {
Text(
text = "✓✓",
text = stringResource(R.string.status_delivered),
fontSize = 10.sp,
color = Color(0xFF007AFF), // Blue
fontWeight = FontWeight.Bold
@@ -502,7 +505,7 @@ fun DeliveryStatusIcon(status: DeliveryStatus) {
}
is DeliveryStatus.Failed -> {
Text(
text = "",
text = stringResource(R.string.status_failed),
fontSize = 10.sp,
color = Color.Red.copy(alpha = 0.8f)
)
@@ -510,7 +513,7 @@ fun DeliveryStatusIcon(status: DeliveryStatus) {
is DeliveryStatus.PartiallyDelivered -> {
// Show a single subdued check without numeric label
Text(
text = "",
text = stringResource(R.string.status_sent),
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.6f)
)
@@ -13,8 +13,8 @@ class MessageManager(private val state: ChatState) {
// Message deduplication - FIXED: Prevent duplicate messages from dual connection paths
private val processedUIMessages = Collections.synchronizedSet(mutableSetOf<String>())
private val recentSystemEvents = Collections.synchronizedMap(mutableMapOf<String, Long>())
private val MESSAGE_DEDUP_TIMEOUT = 30000L // 30 seconds
private val SYSTEM_EVENT_DEDUP_TIMEOUT = 5000L // 5 seconds
private val MESSAGE_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.UI.MESSAGE_DEDUP_TIMEOUT_MS // 30 seconds
private val SYSTEM_EVENT_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.UI.SYSTEM_EVENT_DEDUP_TIMEOUT_MS // 5 seconds
// MARK: - Public Message Management
@@ -37,6 +37,7 @@ class MessageManager(private val state: ChatState) {
fun clearMessages() {
state.setMessages(emptyList())
state.setChannelMessages(emptyMap())
}
// MARK: - Channel Message Management
@@ -42,7 +42,7 @@ class NotificationManager(
private const val SUMMARY_NOTIFICATION_ID = 999
private const val GEOHASH_SUMMARY_NOTIFICATION_ID = 998
private const val ACTIVE_PEERS_NOTIFICATION_ID = 997
private const val ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL = 300_000L
private const val ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL = com.bitchat.android.util.AppConstants.UI.ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS
// Intent extras for notification handling
const val EXTRA_OPEN_PRIVATE_CHAT = "open_private_chat"
@@ -258,7 +258,10 @@ class NotificationManager(
}
if (messageCount > 5) {
style.setSummaryText("and ${messageCount - 5} more")
val extra = messageCount - 5
style.setSummaryText(context.resources.getQuantityString(
R.plurals.notification_and_more, extra, extra
))
}
builder.setStyle(style)
@@ -291,11 +294,11 @@ class NotificationManager(
)
// Build notification content
val contentTitle = "👥 bitchatters nearby!"
val contentTitle = context.getString(R.string.notification_active_peers_title)
val contentText = if (peersSize == 1) {
"1 person around"
context.getString(R.string.notification_active_peers_one)
} else {
"$peersSize people around"
context.getString(R.string.notification_active_peers_many, peersSize)
}
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
@@ -331,8 +334,8 @@ class NotificationManager(
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("bitchat")
.setContentText("$totalMessages messages from $senderCount people")
.setContentTitle(context.getString(R.string.app_name))
.setContentText(context.getString(R.string.notification_messages_from_people, totalMessages, senderCount))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -342,7 +345,7 @@ class NotificationManager(
// Add inbox style showing recent senders
val style = NotificationCompat.InboxStyle()
.setBigContentTitle("New Messages")
.setBigContentTitle(context.getString(R.string.notification_new_location_messages))
pendingNotifications.entries.take(5).forEach { (peerID, notifications) ->
val latestNotif = notifications.last()
@@ -356,7 +359,7 @@ class NotificationManager(
}
if (pendingNotifications.size > 5) {
style.setSummaryText("and ${pendingNotifications.size - 5} more conversations")
style.setSummaryText(context.getString(R.string.notification_more_conversations, pendingNotifications.size - 5))
}
builder.setStyle(style)
@@ -459,15 +462,15 @@ class NotificationManager(
// Build notification content with location name if available
val geohashDisplay = latestNotification.locationName?.let { "$it (#$geohash)" } ?: "#$geohash"
val contentTitle = when {
mentionCount > 0 && firstMessageCount > 0 && messageCount > 1 -> "Mentioned in $geohashDisplay (+${messageCount - 1} more)"
mentionCount > 0 -> if (mentionCount == 1) "Mentioned in $geohashDisplay" else "$mentionCount mentions in $geohashDisplay"
firstMessageCount > 0 -> "New activity in $geohashDisplay"
else -> "Messages in $geohashDisplay"
mentionCount > 0 && firstMessageCount > 0 && messageCount > 1 -> context.getString(R.string.notification_mentions_in_more, geohashDisplay, messageCount - 1)
mentionCount > 0 -> if (mentionCount == 1) context.getString(R.string.notification_mentions_in, geohashDisplay) else context.getString(R.string.notification_mentions_in_plural, mentionCount, geohashDisplay)
firstMessageCount > 0 -> context.getString(R.string.notification_new_activity_in, geohashDisplay)
else -> context.getString(R.string.notification_messages_in, geohashDisplay)
}
val contentText = when {
latestNotification.isMention -> "${latestNotification.senderNickname}: ${latestNotification.messageContent}"
latestNotification.isFirstMessage -> "${latestNotification.senderNickname} joined the conversation"
latestNotification.isFirstMessage -> context.getString(R.string.notification_joined_conversation, latestNotification.senderNickname)
else -> "${latestNotification.senderNickname}: ${latestNotification.messageContent}"
}
@@ -503,7 +506,8 @@ class NotificationManager(
}
if (messageCount > 5) {
style.setSummaryText("and ${messageCount - 5} more")
val extra = messageCount - 5
style.setSummaryText(context.resources.getQuantityString(R.plurals.notification_and_more, extra, extra))
}
builder.setStyle(style)
@@ -543,12 +547,12 @@ class NotificationManager(
)
val contentTitle = if (totalMentions > 0) {
"bitchat - $totalMentions mentions"
context.getString(R.string.notification_geohash_summary_title_mentions, totalMentions)
} else {
"bitchat - location chats"
context.getString(R.string.notification_geohash_summary_title)
}
val contentText = "$totalMessages messages from $geohashCount locations"
val contentText = context.getString(R.string.notification_geohash_summary_text, totalMessages, geohashCount)
val builder = NotificationCompat.Builder(context, GEOHASH_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
@@ -563,7 +567,7 @@ class NotificationManager(
// Add inbox style showing recent geohashes
val style = NotificationCompat.InboxStyle()
.setBigContentTitle("New Location Messages")
.setBigContentTitle(context.getString(R.string.notification_new_messages))
pendingGeohashNotifications.entries.take(5).forEach { (geohash, notifications) ->
val mentionCount = notifications.count { it.isMention }
@@ -579,7 +583,7 @@ class NotificationManager(
}
if (pendingGeohashNotifications.size > 5) {
style.setSummaryText("and ${pendingGeohashNotifications.size - 5} more locations")
style.setSummaryText(context.getString(R.string.notification_more_locations, pendingGeohashNotifications.size - 5))
}
builder.setStyle(style)
@@ -676,9 +680,9 @@ class NotificationManager(
// Build notification content
val contentTitle = if (messageCount == 1) {
"Mentioned in Mesh Chat"
context.getString(R.string.notification_mesh_mention_title_singular)
} else {
"$messageCount mentions in Mesh Chat"
context.getString(R.string.notification_mesh_mention_title_plural, messageCount)
}
val contentText = "${latestNotification.senderNickname}: ${latestNotification.messageContent}"
@@ -710,7 +714,8 @@ class NotificationManager(
}
if (messageCount > 5) {
style.setSummaryText("and ${messageCount - 5} more")
val extra = messageCount - 5
style.setSummaryText(context.resources.getQuantityString(R.plurals.notification_and_more, extra, extra))
}
builder.setStyle(style)
@@ -0,0 +1,28 @@
package com.bitchat.android.ui
import android.content.pm.ActivityInfo
import android.os.Bundle
import androidx.activity.ComponentActivity
import com.bitchat.android.utils.DeviceUtils
/**
* Base activity that automatically sets orientation based on device type.
* Tablets can rotate to landscape, phones are locked to portrait.
*/
abstract class OrientationAwareActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setOrientationBasedOnDeviceType()
}
private fun setOrientationBasedOnDeviceType() {
requestedOrientation = if (DeviceUtils.isTablet(this)) {
// Allow all orientations on tablets
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
} else {
// Lock to portrait on phones
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
}
@@ -15,6 +15,8 @@ import androidx.compose.ui.text.font.FontWeight
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 com.bitchat.android.R
import com.bitchat.android.nostr.PoWPreferenceManager
/**
@@ -54,7 +56,7 @@ fun PoWStatusIndicator(
Icon(
imageVector = Icons.Filled.Security,
contentDescription = "Mining PoW",
contentDescription = stringResource(R.string.cd_mining_pow),
tint = Color(0xFFFF9500), // Orange for mining
modifier = Modifier
.size(12.dp)
@@ -63,7 +65,7 @@ fun PoWStatusIndicator(
} else {
Icon(
imageVector = Icons.Filled.Security,
contentDescription = "PoW Enabled",
contentDescription = stringResource(R.string.cd_pow_enabled),
tint = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), // Green when ready
modifier = Modifier.size(12.dp)
)
@@ -85,7 +87,7 @@ fun PoWStatusIndicator(
// PoW icon
Icon(
imageVector = Icons.Filled.Security,
contentDescription = "Proof of Work",
contentDescription = stringResource(R.string.cd_proof_of_work),
tint = if (isMining) Color(0xFFFF9500) else {
if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
},
@@ -95,9 +97,9 @@ fun PoWStatusIndicator(
// Status text
Text(
text = if (isMining) {
"mining..."
stringResource(R.string.pow_mining_ellipsis)
} else {
"pow: ${powDifficulty}bit"
stringResource(R.string.pow_label_format, powDifficulty)
},
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
@@ -109,7 +111,7 @@ fun PoWStatusIndicator(
// Time estimate
if (!isMining && powDifficulty > 0) {
Text(
text = "(~${NostrProofOfWork.estimateMiningTime(powDifficulty)})",
text = stringResource(R.string.pow_time_estimate, NostrProofOfWork.estimateMiningTime(powDifficulty)),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.5f)
@@ -237,7 +237,7 @@ fun ChannelsSection(
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Leave channel",
contentDescription = stringResource(com.bitchat.android.R.string.cd_leave_channel),
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.5f)
)
@@ -552,7 +552,7 @@ private fun PeerItem(
// Show mail icon for unread DMs (iOS orange)
Icon(
imageVector = Icons.Filled.Email,
contentDescription = "Unread message",
contentDescription = stringResource(com.bitchat.android.R.string.cd_unread_message),
modifier = Modifier.size(16.dp),
tint = Color(0xFFFF9500) // iOS orange
)
@@ -562,7 +562,7 @@ private fun PeerItem(
// Purple globe to indicate Nostr availability
Icon(
imageVector = Icons.Filled.Public,
contentDescription = "Reachable via Nostr",
contentDescription = stringResource(com.bitchat.android.R.string.cd_reachable_via_nostr),
modifier = Modifier.size(16.dp),
tint = Color(0xFF9C27B0) // Purple
)
@@ -571,7 +571,7 @@ private fun PeerItem(
Icon(
//painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_offline_favorite),
imageVector = Icons.Outlined.Circle,
contentDescription = "Offline favorite",
contentDescription = stringResource(com.bitchat.android.R.string.cd_offline_favorite),
modifier = Modifier.size(16.dp),
tint = Color.Gray
)
@@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.unit.dp
@@ -129,7 +130,7 @@ fun VoiceRecordButton(
) {
Icon(
imageVector = Icons.Filled.Mic,
contentDescription = "Record voice note",
contentDescription = stringResource(com.bitchat.android.R.string.cd_record_voice),
tint = Color.Black,
modifier = Modifier.size(20.dp)
)
@@ -29,6 +29,8 @@ import kotlinx.coroutines.launch
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
@Composable
fun MeshTopologySection() {
@@ -182,10 +184,10 @@ fun DebugSettingsSheet(
item {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500))
Text("debug tools", fontFamily = FontFamily.Monospace, fontSize = 18.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_tools), fontFamily = FontFamily.Monospace, fontSize = 18.sp, fontWeight = FontWeight.Medium)
}
Text(
text = "developer utilities for diagnostics and control",
text = stringResource(R.string.debug_tools_desc),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
@@ -198,12 +200,12 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF00C851))
Text("verbose logging", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_verbose_logging), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Switch(checked = verboseLogging, onCheckedChange = { manager.setVerboseLoggingEnabled(it) })
}
Text(
"logs peer joins/leaves, connection direction, packet routing and relays",
stringResource(R.string.debug_verbose_hint),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
@@ -223,10 +225,10 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
Text("bluetooth roles", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("gatt server", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Text(stringResource(R.string.debug_gatt_server), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Switch(checked = gattServerEnabled, onCheckedChange = {
manager.setGattServerEnabled(it)
scope.launch {
@@ -235,9 +237,9 @@ fun DebugSettingsSheet(
})
}
val serverCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_SERVER }
Text("connections: $serverCount / $maxServer", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("max server", fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Text(stringResource(R.string.debug_max_server), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Slider(
value = maxServer.toFloat(),
onValueChange = { manager.setMaxServerConnections(it.toInt().coerceAtLeast(1)) },
@@ -246,7 +248,7 @@ fun DebugSettingsSheet(
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("gatt client", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Text(stringResource(R.string.debug_gatt_client), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Switch(checked = gattClientEnabled, onCheckedChange = {
manager.setGattClientEnabled(it)
scope.launch {
@@ -255,9 +257,9 @@ fun DebugSettingsSheet(
})
}
val clientCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_CLIENT }
Text("connections: $clientCount / $maxClient", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("max client", fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Text(stringResource(R.string.debug_max_client), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Slider(
value = maxClient.toFloat(),
onValueChange = { manager.setMaxClientConnections(it.toInt().coerceAtLeast(1)) },
@@ -266,9 +268,9 @@ fun DebugSettingsSheet(
)
}
val overallCount = connectedDevices.size
Text("connections: $overallCount / $maxOverall", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("max overall", fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Text(stringResource(R.string.debug_max_overall), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Slider(
value = maxOverall.toFloat(),
onValueChange = { manager.setMaxConnectionsOverall(it.toInt().coerceAtLeast(1)) },
@@ -277,7 +279,7 @@ fun DebugSettingsSheet(
)
}
Text(
"turn roles on/off and close all connections when disabled",
stringResource(R.string.debug_roles_hint),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
@@ -292,12 +294,12 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
Text("packet relay", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
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("since start: ${relayStats.totalRelaysCount}", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
Text("last 10s: ${relayStats.last10SecondRelays} • 1m: ${relayStats.lastMinuteRelays} • 15m: ${relayStats.last15MinuteRelays}", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
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) {
@@ -428,29 +430,32 @@ fun MeshTopologySection() {
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF9C27B0))
Text("sync settings", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
Text("max packets per sync: $seenCapacity", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Slider(value = seenCapacity.toFloat(), onValueChange = { manager.setSeenPacketCapacity(it.toInt()) }, valueRange = 10f..1000f, steps = 99)
Text("max GCS filter size: $gcsMaxBytes bytes (1281024)", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Slider(value = gcsMaxBytes.toFloat(), onValueChange = { manager.setGcsMaxBytes(it.toInt()) }, valueRange = 128f..1024f, steps = 0)
Text("target FPR: ${String.format("%.2f", gcsFpr)}%", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Slider(value = gcsFpr.toFloat(), onValueChange = { manager.setGcsFprPercent(it.toDouble()) }, valueRange = 0.1f..5.0f, steps = 49)
val p = remember(gcsFpr) { com.bitchat.android.sync.GCSFilter.deriveP(gcsFpr / 100.0) }
val nmax = remember(gcsFpr, gcsMaxBytes) { com.bitchat.android.sync.GCSFilter.estimateMaxElementsForSize(gcsMaxBytes, p) }
Text("derived P: $p • est. max elements: $nmax", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
}
}
// Connected devices
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50))
Text("connected devices", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_connected_devices), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
val localAddr = remember { meshService.connectionManager.getLocalAdapterAddress() }
Text("our device id: ${localAddr ?: "unknown"}", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
if (connectedDevices.isEmpty()) {
Text("none", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
@@ -458,11 +463,11 @@ fun MeshTopologySection() {
Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) {
Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("${dev.peerID ?: "unknown"}${dev.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
val roleLabel = if (dev.connectionType == ConnectionType.GATT_SERVER) "as server (we host)" else "as client (we connect)"
Text("${dev.nickname ?: ""}RSSI: ${dev.rssi ?: "?"}$roleLabel${if (dev.isDirectConnection) " • direct" else ""}", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text((dev.peerID ?: stringResource(R.string.unknown)) + "${dev.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
val roleLabel = if (dev.connectionType == ConnectionType.GATT_SERVER) stringResource(R.string.debug_role_server) else stringResource(R.string.debug_role_client)
Text("${dev.nickname ?: ""}" + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + "$roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
Text("disconnect", color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
meshService.connectionManager.disconnectAddress(dev.deviceAddress)
})
}
@@ -479,19 +484,19 @@ fun MeshTopologySection() {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
Text("recent scan results", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_recent_scan_results), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
if (scanResults.isEmpty()) {
Text("none", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
Text(stringResource(R.string.debug_none), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
scanResults.forEach { res ->
Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) {
Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("${res.peerID ?: "unknown"}${res.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text("${res.deviceName ?: ""} • RSSI: ${res.rssi}", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text((res.peerID ?: stringResource(R.string.unknown)) + "${res.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
Text("connect", color = Color(0xFF00C851), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
meshService.connectionManager.connectToAddress(res.deviceAddress)
})
}
@@ -508,9 +513,9 @@ fun MeshTopologySection() {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500))
Text("debug console", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_debug_console), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Text("clear", color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
manager.clearDebugMessages()
})
}
@@ -19,6 +19,8 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import androidx.compose.material3.ColorScheme
@@ -91,7 +93,7 @@ fun AudioMessageItem(
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = "Cancel", tint = Color.White, modifier = Modifier.size(16.dp))
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(16.dp))
}
}
}
@@ -19,6 +19,8 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -28,6 +30,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.bitchat.android.features.file.FileUtils
import com.bitchat.android.model.BitchatFilePacket
@@ -60,7 +63,7 @@ fun FileMessageItem(
// File icon
Icon(
imageVector = Icons.Filled.Description,
contentDescription = "File",
contentDescription = stringResource(com.bitchat.android.R.string.cd_file),
tint = getFileIconColor(packet.fileName),
modifier = Modifier.size(32.dp)
)
@@ -70,13 +73,13 @@ fun FileMessageItem(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// File name
Text(
text = packet.fileName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = packet.fileName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// File details
Row(
@@ -14,6 +14,8 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import com.bitchat.android.features.file.FileUtils
@Composable
@@ -44,7 +46,7 @@ fun FilePickerButton(
) {
Icon(
imageVector = Icons.Filled.Attachment,
contentDescription = "Pick file",
contentDescription = stringResource(R.string.cd_pick_file),
tint = Color.Gray,
modifier = Modifier.size(20.dp).rotate(90f)
)
@@ -28,6 +28,9 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import kotlinx.coroutines.delay
/**
@@ -77,7 +80,7 @@ fun FileSendingAnimation(
// File icon
Icon(
imageVector = Icons.Filled.Description,
contentDescription = "File",
contentDescription = stringResource(R.string.cd_file),
tint = Color(0xFF00C851), // Green like app theme
modifier = Modifier.size(32.dp)
)
@@ -102,7 +105,7 @@ fun FileSendingAnimation(
// Blinking cursor (only if not fully revealed)
if (animatedChars < fileName.length && showCursor) {
androidx.compose.material3.Text(
text = "_",
text = stringResource(R.string.underscore),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = Color.White
@@ -132,10 +135,12 @@ private fun FileProgressBars(
val filledBars = (progress * bars).toInt()
// Create a matrix-style progress bar string
val ctx = LocalContext.current
val progressString = buildString {
val brackets = ctx.getString(R.string.progress_bar_brackets, "", 0)
append("[")
for (i in 0 until bars) {
append(if (i < filledBars) "" else "")
append(if (i < filledBars) ctx.getString(R.string.progress_filled) else ctx.getString(R.string.progress_empty))
}
append("] ")
append("${(progress * 100).toInt()}%")
@@ -20,9 +20,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.bitchat.android.R
import com.bitchat.android.features.file.FileUtils
import com.bitchat.android.model.BitchatFilePacket
import kotlinx.coroutines.launch
@@ -54,7 +56,7 @@ fun FileViewerDialog(
) {
// File received header
Text(
text = "📎 File Received",
text = stringResource(R.string.file_viewer_title),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
@@ -65,17 +67,17 @@ fun FileViewerDialog(
horizontalAlignment = Alignment.Start
) {
Text(
text = "📄 ${packet.fileName}",
text = stringResource(R.string.file_viewer_name, packet.fileName),
style = MaterialTheme.typography.bodyLarge,
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
)
Text(
text = "📏 Size: ${FileUtils.formatFileSize(packet.fileSize)}",
text = stringResource(R.string.file_viewer_size, FileUtils.formatFileSize(packet.fileSize)),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "🏷️ Type: ${packet.mimeType}",
text = stringResource(R.string.file_viewer_type, packet.mimeType),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -108,7 +110,7 @@ fun FileViewerDialog(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text("📂 Open / Save")
Text(stringResource(R.string.file_viewer_open_save))
}
// Dismiss button
@@ -119,13 +121,13 @@ fun FileViewerDialog(
containerColor = MaterialTheme.colorScheme.secondary
)
) {
Text("❌ Close")
Text(stringResource(R.string.close_with_emoji))
}
}
}
}
}
}
}
/**
* Attempts to open a file using system viewers or save to device
@@ -32,6 +32,8 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import java.io.File
/**
@@ -75,13 +77,13 @@ fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClo
bmp?.let {
androidx.compose.foundation.Image(
bitmap = it.asImageBitmap(),
contentDescription = "Image ${page + 1} of ${imagePaths.size}",
contentDescription = stringResource(R.string.cd_image_index_of, page + 1, imagePaths.size),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
} ?: run {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "Image unavailable", color = Color.White)
Text(text = stringResource(R.string.image_unavailable), color = Color.White)
}
}
}
@@ -96,7 +98,7 @@ fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClo
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
Text(
text = "${(pagerState.currentPage ?: 0) + 1} / ${imagePaths.size}",
text = stringResource(R.string.image_counter, (pagerState.currentPage ?: 0) + 1, imagePaths.size),
color = Color.White,
fontSize = 14.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
@@ -118,7 +120,7 @@ fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClo
.clickable { saveToDownloads(context, imagePaths[pagerState.currentPage].toString()) },
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Icon(Icons.Filled.Download, "Save current image", tint = Color.White)
androidx.compose.material3.Icon(Icons.Filled.Download, stringResource(R.string.cd_save_current_image), tint = Color.White)
}
Spacer(Modifier.width(12.dp))
Box(
@@ -128,7 +130,7 @@ fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClo
.clickable { onClose() },
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Icon(Icons.Filled.Close, "Close", tint = Color.White)
androidx.compose.material3.Icon(Icons.Filled.Close, stringResource(R.string.cd_close), tint = Color.White)
}
}
}
@@ -161,10 +163,10 @@ private fun saveToDownloads(context: android.content.Context, path: String) {
context.contentResolver.update(uri, v2, null, null)
}
// Show toast message indicating the image has been saved
Toast.makeText(context, "Image saved to Downloads", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.toast_image_saved), Toast.LENGTH_SHORT).show()
}
}.onFailure {
// Optionally handle failure case (e.g., show error toast)
Toast.makeText(context, "Failed to save image", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.toast_failed_to_save_image), Toast.LENGTH_SHORT).show()
}
}
@@ -16,15 +16,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.text.font.FontFamily
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
@@ -113,7 +115,7 @@ fun ImageMessageItem(
// Fully revealed image
Image(
bitmap = img,
contentDescription = "Image",
contentDescription = stringResource(com.bitchat.android.R.string.cd_image),
modifier = Modifier
.widthIn(max = 300.dp)
.aspectRatio(aspect)
@@ -137,13 +139,13 @@ fun ImageMessageItem(
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = "Cancel", tint = Color.White, modifier = Modifier.size(14.dp))
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(com.bitchat.android.R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp))
}
}
}
}
} else {
Text(text = "[image unavailable]", fontFamily = FontFamily.Monospace, color = Color.Gray)
Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray)
}
}
}
@@ -1,25 +1,37 @@
package com.bitchat.android.ui.media
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Camera
import androidx.compose.material.icons.filled.Photo
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import com.bitchat.android.features.media.ImageUtils
import java.io.File
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ImagePickerButton(
modifier: Modifier = Modifier,
onImageReady: (String) -> Unit
) {
val context = LocalContext.current
var capturedImagePath by remember { mutableStateOf<String?>(null) }
val imagePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: android.net.Uri? ->
@@ -28,17 +40,57 @@ fun ImagePickerButton(
if (!outPath.isNullOrBlank()) onImageReady(outPath)
}
}
val takePictureLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success ->
val path = capturedImagePath
if (success && !path.isNullOrBlank()) {
// Downscale + correct orientation, then send; delete original
val outPath = com.bitchat.android.features.media.ImageUtils.downscalePathAndSaveToAppFiles(context, path)
if (!outPath.isNullOrBlank()) {
onImageReady(outPath)
}
runCatching { File(path).delete() }
} else {
// Cleanup on cancel/failure
path?.let { runCatching { File(it).delete() } }
}
capturedImagePath = null
}
IconButton(
onClick = { imagePicker.launch("image/*") },
modifier = modifier.size(32.dp)
fun startCameraCapture() {
try {
val dir = File(context.filesDir, "images/outgoing").apply { mkdirs() }
val file = File(dir, "camera_${System.currentTimeMillis()}.jpg")
val uri = FileProvider.getUriForFile(
context,
context.packageName + ".fileprovider",
file
)
capturedImagePath = file.absolutePath
takePictureLauncher.launch(uri)
} catch (_: Exception) {
// Ignore errors; no-op
}
}
Box(
modifier = modifier
.size(32.dp)
.combinedClickable(
onClick = { imagePicker.launch("image/*") },
onLongClick = { startCameraCapture() }
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Photo,
contentDescription = "Pick image",
imageVector = Icons.Filled.PhotoCamera,
contentDescription = stringResource(com.bitchat.android.R.string.pick_image),
tint = Color.Gray,
modifier = Modifier.size(20.dp)
)
}
}
// No custom preview: native camera UI handles confirmation
}
@@ -26,6 +26,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
/**
* Media picker that offers image and file options
@@ -53,7 +55,7 @@ fun MediaPickerOptions(
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Pick media",
contentDescription = stringResource(R.string.cd_pick_media),
tint = Color.Black,
modifier = Modifier.size(20.dp)
)
@@ -98,7 +100,7 @@ fun MediaPickerOptions(
modifier = Modifier.size(16.dp)
)
androidx.compose.material3.Text(
text = "Image",
text = stringResource(R.string.media_type_image),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
@@ -126,7 +128,7 @@ fun MediaPickerOptions(
modifier = Modifier.size(16.dp)
)
androidx.compose.material3.Text(
text = "File",
text = stringResource(R.string.media_type_file),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
@@ -7,7 +7,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Base font size for consistent scaling across the app
const val BASE_FONT_SIZE = 15 // sp - increased from 14sp for better readability
internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP // sp - increased from 14sp for better readability
// Typography matching the iOS monospace design - using BASE_FONT_SIZE for consistency
val Typography = Typography(
@@ -0,0 +1,127 @@
package com.bitchat.android.util
import java.util.UUID
/**
* Centralized application-wide constants.
*/
object AppConstants {
// Packet time-to-live (hops)
val MESSAGE_TTL_HOPS: UByte = 7u // Default TTL for regular packets
val SYNC_TTL_HOPS: UByte = 0u // TTL for neighbor-only sync packets
object Mesh {
// Peer lifecycle
const val STALE_PEER_TIMEOUT_MS: Long = 180_000L // 3 minutes
const val PEER_CLEANUP_INTERVAL_MS: Long = 60_000L
// BLE connection tracking
const val CONNECTION_RETRY_DELAY_MS: Long = 5_000L
const val MAX_CONNECTION_ATTEMPTS: Int = 3
const val CONNECTION_CLEANUP_DELAY_MS: Long = 500L
const val CONNECTION_CLEANUP_INTERVAL_MS: Long = 30_000L
const val BROADCAST_CLEANUP_DELAY_MS: Long = 500L
// GATT client RSSI updates
const val RSSI_UPDATE_INTERVAL_MS: Long = 5_000L
object Gatt {
val SERVICE_UUID: UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
val CHARACTERISTIC_UUID: UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
val DESCRIPTOR_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
}
}
object Sync {
const val CLEANUP_INTERVAL_MS: Long = 60_000L
}
object Fragmentation {
const val FRAGMENT_SIZE_THRESHOLD: Int = 512
const val MAX_FRAGMENT_SIZE: Int = 469
const val FRAGMENT_TIMEOUT_MS: Long = 30_000L
const val CLEANUP_INTERVAL_MS: Long = 10_000L
}
object Security {
const val MESSAGE_TIMEOUT_MS: Long = 300_000L
const val CLEANUP_INTERVAL_MS: Long = 300_000L
const val MAX_PROCESSED_MESSAGES: Int = 10_000
const val MAX_PROCESSED_KEY_EXCHANGES: Int = 1_000
}
object Noise {
const val REKEY_TIME_LIMIT_MS: Long = 3_600_000L // 1 hour
const val REKEY_MESSAGE_LIMIT_ENCRYPTION: Long = 1_000L // per session, encryption service policy
const val REKEY_MESSAGE_LIMIT_SESSION: Long = 10_000L // session-level ceiling
const val MAX_PAYLOAD_SIZE_BYTES: Int = 256
const val HIGH_NONCE_WARNING_THRESHOLD: Long = 1_000_000_000L
}
object Protocol {
const val COMPRESSION_THRESHOLD_BYTES: Int = 100
}
object StoreForward {
const val MESSAGE_CACHE_TIMEOUT_MS: Long = 43_200_000L // 12h
const val MAX_CACHED_MESSAGES: Int = 100
const val MAX_CACHED_MESSAGES_FAVORITES: Int = 1_000
const val CLEANUP_INTERVAL_MS: Long = 600_000L
}
object Power {
const val CRITICAL_BATTERY_PERCENT: Int = 10
const val LOW_BATTERY_PERCENT: Int = 20
const val MEDIUM_BATTERY_PERCENT: Int = 50
const val SCAN_ON_DURATION_NORMAL_MS: Long = 8_000L
const val SCAN_OFF_DURATION_NORMAL_MS: Long = 2_000L
const val SCAN_ON_DURATION_POWER_SAVE_MS: Long = 2_000L
const val SCAN_OFF_DURATION_POWER_SAVE_MS: Long = 8_000L
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
}
object Nostr {
// Relay backoff
const val INITIAL_BACKOFF_INTERVAL_MS: Long = 1_000L
const val MAX_BACKOFF_INTERVAL_MS: Long = 300_000L
const val BACKOFF_MULTIPLIER: Double = 2.0
const val MAX_RECONNECT_ATTEMPTS: Int = 10
// Transport
const val READ_ACK_INTERVAL_MS: Long = 350L
// Deduplicator
const val DEFAULT_DEDUP_CAPACITY: Int = 10_000
// Relay subscription validation
const val SUBSCRIPTION_VALIDATION_INTERVAL_MS: Long = 30_000L
}
object Tor {
const val DEFAULT_SOCKS_PORT: Int = 9060
const val RESTART_DELAY_MS: Long = 2_000L
const val INACTIVITY_TIMEOUT_MS: Long = 5_000L
const val MAX_RETRY_ATTEMPTS: Int = 5
const val STOP_TIMEOUT_MS: Long = 7_000L
}
object UI {
const val MAX_NICKNAME_LENGTH: Int = 15
const val BASE_FONT_SIZE_SP: Int = 15
const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L
const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L
const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L
}
object Media {
const val MAX_FILE_SIZE_BYTES: Long = 50L * 1024 * 1024
}
object Services {
const val SEEN_MESSAGE_MAX_IDS: Int = 10_000
}
}
@@ -0,0 +1,39 @@
package com.bitchat.android.utils
import android.content.Context
import android.content.res.Configuration
import android.util.DisplayMetrics
import android.view.WindowManager
import androidx.core.content.getSystemService
import kotlin.math.sqrt
object DeviceUtils {
/**
* Determines if the current device is a tablet based on screen size and density.
* Uses multiple criteria to accurately detect tablets vs phones.
*/
fun isTablet(context: Context): Boolean {
val windowManager = context.getSystemService<WindowManager>()
val displayMetrics = DisplayMetrics()
windowManager?.defaultDisplay?.getMetrics(displayMetrics)
// Calculate screen size in inches
val widthInches = displayMetrics.widthPixels / displayMetrics.xdpi
val heightInches = displayMetrics.heightPixels / displayMetrics.ydpi
val diagonalInches = sqrt((widthInches * widthInches) + (heightInches * heightInches))
// Check if device has tablet configuration
val configuration = context.resources.configuration
val isLargeScreen = (configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE
val isXLargeScreen = (configuration.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK) == Configuration.SCREENLAYOUT_SIZE_XLARGE
// A device is considered a tablet if:
// 1. Screen diagonal is 7 inches or larger, OR
// 2. Configuration indicates large or xlarge screen, OR
// 3. Smallest width is 600dp or more (sw600dp)
val smallestWidthDp = context.resources.configuration.smallestScreenWidthDp
return diagonalInches >= 7.0 || isLargeScreen || isXLargeScreen || smallestWidthDp >= 600
}
}