mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 05:05:20 +00:00
Merge branch 'main' into gossip-routing-2
This commit is contained in:
@@ -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 (128–1024)", 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user