mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 08:25:22 +00:00
Resolve merge conflicts:
- BluetoothConnectionManager.kt: keep main version, rename sendToPeer -> sendPacketToPeer and add overload; remove conflict markers - BluetoothMeshService.kt: keep main, ensure delegate uses sendPacketToPeer - BinaryProtocol.kt: keep main version with v2 payload length support Refactor: rename all sendToPeer usages to sendPacketToPeer across codebase - PacketRelayManager + interface - PacketProcessor + interface - BluetoothPacketBroadcaster targeted send method remains sendPacketToPeer - Update tests accordingly Build fixes
This commit is contained in:
@@ -25,6 +25,7 @@ import com.bitchat.android.onboarding.BluetoothCheckScreen
|
||||
import com.bitchat.android.onboarding.BluetoothStatus
|
||||
import com.bitchat.android.onboarding.BluetoothStatusManager
|
||||
import com.bitchat.android.onboarding.BatteryOptimizationManager
|
||||
import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager
|
||||
import com.bitchat.android.onboarding.BatteryOptimizationScreen
|
||||
import com.bitchat.android.onboarding.BatteryOptimizationStatus
|
||||
import com.bitchat.android.onboarding.InitializationErrorScreen
|
||||
@@ -163,7 +164,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
when (onboardingState) {
|
||||
OnboardingState.CHECKING -> {
|
||||
OnboardingState.PERMISSION_REQUESTING -> {
|
||||
InitializingScreen(modifier)
|
||||
}
|
||||
|
||||
@@ -226,16 +227,8 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OnboardingState.PERMISSION_REQUESTING -> {
|
||||
InitializingScreen(modifier)
|
||||
}
|
||||
|
||||
OnboardingState.INITIALIZING -> {
|
||||
InitializingScreen(modifier)
|
||||
}
|
||||
|
||||
OnboardingState.COMPLETE -> {
|
||||
|
||||
OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> {
|
||||
// Set up back navigation handling for the chat screen
|
||||
val backCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
@@ -533,6 +526,13 @@ class MainActivity : ComponentActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has previously skipped battery optimization
|
||||
if (BatteryOptimizationPreferenceManager.isSkipped(this)) {
|
||||
android.util.Log.d("MainActivity", "User previously skipped battery optimization, proceeding to permissions")
|
||||
proceedWithPermissionCheck()
|
||||
return
|
||||
}
|
||||
|
||||
// For existing users, check battery optimization status
|
||||
batteryOptimizationManager.logBatteryOptimizationStatus()
|
||||
val currentBatteryOptimizationStatus = when {
|
||||
|
||||
@@ -138,6 +138,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...")
|
||||
}
|
||||
|
||||
|
||||
/** NEW: Update Nostr pubkey for specific mesh peerID (16-hex). */
|
||||
fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) {
|
||||
val pid = peerID.lowercase()
|
||||
@@ -150,6 +151,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** NEW: Resolve Nostr pubkey via current peerID mapping (fast path). */
|
||||
fun findNostrPubkeyForPeerID(peerID: String): String? {
|
||||
return peerIdIndex[peerID.lowercase()]
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.bitchat.android.features.file
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
object FileUtils {
|
||||
|
||||
private const val TAG = "FileUtils"
|
||||
|
||||
/**
|
||||
* Save a file from URI to app's file directory with unique filename
|
||||
*/
|
||||
fun saveFileFromUri(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
originalName: String? = null
|
||||
): String? {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
if (inputStream == null) {
|
||||
Log.e(TAG, "❌ Failed to open input stream for URI: $uri")
|
||||
return null
|
||||
}
|
||||
Log.d(TAG, "📂 Opened input stream successfully")
|
||||
|
||||
// Determine file extension
|
||||
val extension = originalName?.substringAfterLast(".") ?: "bin"
|
||||
val fileName = "file_${System.currentTimeMillis()}.$extension"
|
||||
|
||||
// Create incoming dir if needed
|
||||
val incomingDir = File(context.filesDir, "files/incoming").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
||||
val file = File(incomingDir, fileName)
|
||||
|
||||
inputStream.use { input ->
|
||||
FileOutputStream(file).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Saved file to: ${file.absolutePath}")
|
||||
file.absolutePath
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save file from URI", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file to app's outgoing directory for sending
|
||||
*/
|
||||
fun copyFileForSending(context: Context, uri: Uri, originalName: String? = null): String? {
|
||||
Log.d(TAG, "🔄 Starting file copy from URI: $uri")
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
if (inputStream == null) {
|
||||
Log.e(TAG, "❌ Failed to open input stream for URI: $uri")
|
||||
return null
|
||||
}
|
||||
Log.d(TAG, "📂 Opened input stream successfully")
|
||||
|
||||
// Determine original filename and extension if available
|
||||
val displayName = originalName ?: run {
|
||||
try {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.DISPLAY_NAME)
|
||||
if (nameIndex >= 0 && cursor.moveToFirst()) cursor.getString(nameIndex) else null
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
val extension = displayName?.substringAfterLast('.', missingDelimiterValue = "")?.takeIf { it.isNotBlank() }
|
||||
?: run {
|
||||
// Try mime type to extension
|
||||
val mime = try { context.contentResolver.getType(uri) } catch (_: Exception) { null }
|
||||
android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "bin"
|
||||
}
|
||||
// Preserve original filename (without artificial prefixes), ensure uniqueness
|
||||
val baseName = displayName?.substringBeforeLast('.')?.take(64)?.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
?: "file"
|
||||
var fileName = if (extension.isNotBlank()) "$baseName.$extension" else baseName
|
||||
|
||||
// Create outgoing dir if needed
|
||||
val outgoingDir = File(context.filesDir, "files/outgoing").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
||||
var target = File(outgoingDir, fileName)
|
||||
if (target.exists()) {
|
||||
var idx = 1
|
||||
val pureBase = baseName
|
||||
val dotExt = if (extension.isNotBlank()) ".${extension}" else ""
|
||||
while (target.exists() && idx < 1000) {
|
||||
fileName = "$pureBase ($idx)$dotExt"
|
||||
target = File(outgoingDir, fileName)
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
inputStream.use { input ->
|
||||
FileOutputStream(target).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "✅ Successfully copied file for sending: ${target.absolutePath}")
|
||||
Log.d(TAG, "📊 Final file size: ${target.length()} bytes")
|
||||
target.absolutePath
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ CRITICAL: Failed to copy file for sending", e)
|
||||
Log.e(TAG, "❌ Source URI: $uri")
|
||||
Log.e(TAG, "❌ Original name: $originalName")
|
||||
Log.e(TAG, "❌ Error type: ${e.javaClass.simpleName}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for a file based on extension
|
||||
*/
|
||||
fun getMimeTypeFromExtension(fileName: String): String {
|
||||
return when (fileName.substringAfterLast(".", "").lowercase()) {
|
||||
"pdf" -> "application/pdf"
|
||||
"doc" -> "application/msword"
|
||||
"docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
"xls" -> "application/vnd.ms-excel"
|
||||
"xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
"ppt" -> "application/vnd.ms-powerpoint"
|
||||
"pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
"txt" -> "text/plain"
|
||||
"json" -> "application/json"
|
||||
"xml" -> "application/xml"
|
||||
"csv" -> "text/csv"
|
||||
"html", "htm" -> "text/html"
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"png" -> "image/png"
|
||||
"gif" -> "image/gif"
|
||||
"bmp" -> "image/bmp"
|
||||
"webp" -> "image/webp"
|
||||
"svg" -> "image/svg+xml"
|
||||
"mp3" -> "audio/mpeg"
|
||||
"wav" -> "audio/wav"
|
||||
"m4a" -> "audio/mp4"
|
||||
"mp4" -> "video/mp4"
|
||||
"avi" -> "video/x-msvideo"
|
||||
"mov" -> "video/quicktime"
|
||||
"zip" -> "application/zip"
|
||||
"rar" -> "application/vnd.rar"
|
||||
"7z" -> "application/x-7z-compressed"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
fun formatFileSize(bytes: Long): String {
|
||||
val units = arrayOf("B", "KB", "MB", "GB")
|
||||
var size = bytes.toDouble()
|
||||
var unitIndex = 0
|
||||
while (size >= 1024 && unitIndex < units.size - 1) {
|
||||
size /= 1024.0
|
||||
unitIndex++
|
||||
}
|
||||
return "%.1f %s".format(size, units[unitIndex])
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is viewable in system viewer
|
||||
*/
|
||||
fun isFileViewable(fileName: String): Boolean {
|
||||
val extension = fileName.substringAfterLast(".", "").lowercase()
|
||||
return extension in listOf(
|
||||
"pdf", "txt", "json", "xml", "html", "htm", "csv",
|
||||
"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an incoming file packet to app storage and return absolute path.
|
||||
* Mirrors existing behavior used in MessageHandler (preserves names and folders).
|
||||
*/
|
||||
fun saveIncomingFile(
|
||||
context: Context,
|
||||
file: com.bitchat.android.model.BitchatFilePacket
|
||||
): String {
|
||||
val lowerMime = file.mimeType.lowercase()
|
||||
val isImage = lowerMime.startsWith("image/")
|
||||
val baseDir = context.filesDir
|
||||
val subdir = if (isImage) "images/incoming" else "files/incoming"
|
||||
val dir = java.io.File(baseDir, subdir).apply { mkdirs() }
|
||||
|
||||
fun extFromMime(m: String): String = when (m.lowercase()) {
|
||||
"image/jpeg", "image/jpg" -> ".jpg"
|
||||
"image/png" -> ".png"
|
||||
"image/webp" -> ".webp"
|
||||
"application/pdf" -> ".pdf"
|
||||
"text/plain" -> ".txt"
|
||||
else -> if (isImage) ".jpg" else ".bin"
|
||||
}
|
||||
|
||||
// Prefer transmitted original name; ensure uniqueness to avoid overwrites
|
||||
val baseName = (file.fileName.takeIf { it.isNotBlank() }
|
||||
?: (if (isImage) "img" else "file"))
|
||||
.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
val ext = extFromMime(lowerMime)
|
||||
var safeName = if (baseName.contains('.')) baseName else baseName + ext
|
||||
var idx = 1
|
||||
while (java.io.File(dir, safeName).exists() && idx < 1000) {
|
||||
val dot = safeName.lastIndexOf('.')
|
||||
safeName = if (dot > 0) {
|
||||
val b = safeName.substring(0, dot)
|
||||
val e = safeName.substring(dot)
|
||||
"$b ($idx)$e"
|
||||
} else {
|
||||
"$safeName ($idx)"
|
||||
}
|
||||
idx++
|
||||
}
|
||||
|
||||
return try {
|
||||
val out = java.io.File(dir, safeName)
|
||||
out.outputStream().use { it.write(file.content) }
|
||||
out.absolutePath
|
||||
} catch (_: Exception) {
|
||||
// Fallback to cache dir with uniqueness
|
||||
try {
|
||||
var fallback = safeName
|
||||
var idx2 = 1
|
||||
while (java.io.File(context.cacheDir, fallback).exists() && idx2 < 1000) {
|
||||
val dot = fallback.lastIndexOf('.')
|
||||
fallback = if (dot > 0) {
|
||||
val b = fallback.substring(0, dot)
|
||||
val e = fallback.substring(dot)
|
||||
"$b ($idx2)$e"
|
||||
} else {
|
||||
"$fallback ($idx2)"
|
||||
}
|
||||
idx2++
|
||||
}
|
||||
val out = java.io.File(context.cacheDir, fallback)
|
||||
out.outputStream().use { it.write(file.content) }
|
||||
out.absolutePath
|
||||
} catch (_: Exception) {
|
||||
val tmp = java.io.File.createTempFile(if (isImage) "img_" else "file_", if (isImage) ".jpg" else ".bin")
|
||||
tmp.writeBytes(file.content)
|
||||
tmp.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify BitchatMessageType from MIME string used in file messages.
|
||||
*/
|
||||
fun messageTypeForMime(mime: String): com.bitchat.android.model.BitchatMessageType {
|
||||
val lower = mime.lowercase()
|
||||
return when {
|
||||
lower.startsWith("image/") -> com.bitchat.android.model.BitchatMessageType.Image
|
||||
lower.startsWith("audio/") -> com.bitchat.android.model.BitchatMessageType.Audio
|
||||
else -> com.bitchat.android.model.BitchatMessageType.File
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.bitchat.android.features.media
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
object ImageUtils {
|
||||
fun downscaleAndSaveToAppFiles(context: Context, uri: android.net.Uri, maxDim: Int = 512, quality: Int = 85): String? {
|
||||
return try {
|
||||
val resolver = context.contentResolver
|
||||
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 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 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) {}
|
||||
outFile.absolutePath
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Simple MediaRecorder wrapper that records to M4A (AAC) for wide compatibility.
|
||||
* The resulting file has MIME audio/mp4.
|
||||
*/
|
||||
class VoiceRecorder(private val context: Context) {
|
||||
companion object { private const val TAG = "VoiceRecorder" }
|
||||
|
||||
private var recorder: MediaRecorder? = null
|
||||
private val _amplitude = MutableStateFlow(0)
|
||||
val amplitude: StateFlow<Int> = _amplitude.asStateFlow()
|
||||
|
||||
private var outFile: File? = null
|
||||
|
||||
fun start(): File? {
|
||||
stop() // ensure previous session closed
|
||||
return try {
|
||||
val dir = File(context.filesDir, "voicenotes/outgoing").apply { mkdirs() }
|
||||
val name = "voice_" + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + ".m4a"
|
||||
val file = File(dir, name)
|
||||
val rec = MediaRecorder()
|
||||
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
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)
|
||||
rec.setOutputFile(file.absolutePath)
|
||||
rec.prepare()
|
||||
rec.start()
|
||||
recorder = rec
|
||||
outFile = file
|
||||
file
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start recording: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun pollAmplitude(): Int {
|
||||
return try {
|
||||
val amp = recorder?.maxAmplitude ?: 0
|
||||
_amplitude.value = amp
|
||||
amp
|
||||
} catch (_: Exception) { 0 }
|
||||
}
|
||||
|
||||
fun stop(): File? {
|
||||
try {
|
||||
recorder?.apply {
|
||||
try { stop() } catch (_: Exception) {}
|
||||
try { reset() } catch (_: Exception) {}
|
||||
try { release() } catch (_: Exception) {}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
val f = outFile
|
||||
recorder = null
|
||||
outFile = null
|
||||
return f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
fun CyberpunkVisualizer(amplitude: Int, color: Color, modifier: Modifier = Modifier) {
|
||||
val norm = min(1f, amplitude / 20_000f)
|
||||
val heightFrac by animateFloatAsState(
|
||||
targetValue = 0.1f + 0.9f * norm,
|
||||
animationSpec = tween(120, easing = LinearEasing), label = "amp"
|
||||
)
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val barCount = 24
|
||||
val gap = 6f
|
||||
val bw = (w - gap * (barCount - 1)) / barCount
|
||||
for (i in 0 until barCount) {
|
||||
val phase = (i.toFloat() / barCount)
|
||||
val barH = (0.2f + heightFrac * (0.8f * (0.5f + 0.5f * kotlin.math.sin(phase * Math.PI * 2).toFloat()))) * h
|
||||
val x = i * (bw + gap)
|
||||
val y = (h - barH) / 2f
|
||||
drawRect(color.copy(alpha = 0.85f), topLeft = androidx.compose.ui.geometry.Offset(x, y), size = androidx.compose.ui.geometry.Size(bw, barH))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
object VoiceWaveformCache {
|
||||
private val map = ConcurrentHashMap<String, FloatArray>()
|
||||
fun put(path: String, samples: FloatArray) { map[path] = samples }
|
||||
fun get(path: String): FloatArray? = map[path]
|
||||
}
|
||||
|
||||
fun normalizeAmplitudeSample(amp: Int): Float {
|
||||
val a = max(0, amp)
|
||||
val norm = ln(1.0 + a.toDouble()) / ln(1.0 + 32768.0)
|
||||
return norm.toFloat().coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
fun resampleWave(values: FloatArray, target: Int): FloatArray {
|
||||
if (values.isEmpty() || target <= 0) return FloatArray(target) { 0f }
|
||||
if (values.size == target) return values
|
||||
val out = FloatArray(target)
|
||||
val step = (values.size - 1).toFloat() / (target - 1).toFloat()
|
||||
var x = 0f
|
||||
for (i in 0 until target) {
|
||||
val idx = x.toInt()
|
||||
val frac = x - idx
|
||||
val a = values[idx]
|
||||
val b = values[min(values.size - 1, idx + 1)]
|
||||
out[i] = (a + (b - a) * frac).coerceIn(0f, 1f)
|
||||
x += step
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
object AudioWaveformExtractor {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
fun extractAsync(path: String, sampleCount: Int = 120, onComplete: (FloatArray?) -> Unit) {
|
||||
scope.launch {
|
||||
onComplete(runCatching { extract(path, sampleCount) }.getOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
private fun extract(path: String, sampleCount: Int): FloatArray? {
|
||||
val extractor = MediaExtractor()
|
||||
extractor.setDataSource(path)
|
||||
val trackIndex = (0 until extractor.trackCount).firstOrNull { idx ->
|
||||
val fmt = extractor.getTrackFormat(idx)
|
||||
val mime = fmt.getString(MediaFormat.KEY_MIME) ?: ""
|
||||
mime.startsWith("audio/")
|
||||
} ?: return null
|
||||
extractor.selectTrack(trackIndex)
|
||||
val format = extractor.getTrackFormat(trackIndex)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: return null
|
||||
val codec = MediaCodec.createDecoderByType(mime)
|
||||
codec.configure(format, null, null, 0)
|
||||
codec.start()
|
||||
|
||||
val durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0L
|
||||
val desiredBins = sampleCount.coerceAtLeast(32)
|
||||
val bins = FloatArray(desiredBins) { 0f }
|
||||
val counts = IntArray(desiredBins) { 0 }
|
||||
|
||||
val inBuffers = codec.inputBuffers
|
||||
val outInfo = MediaCodec.BufferInfo()
|
||||
|
||||
var sawEOS = false
|
||||
while (!sawEOS) {
|
||||
// Queue input
|
||||
val inIndex = codec.dequeueInputBuffer(10_000)
|
||||
if (inIndex >= 0) {
|
||||
val buffer = codec.getInputBuffer(inIndex) ?: inBuffers[inIndex]
|
||||
val sampleSize = extractor.readSampleData(buffer, 0)
|
||||
if (sampleSize < 0) {
|
||||
codec.queueInputBuffer(inIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||
} else {
|
||||
val presentationTimeUs = extractor.sampleTime
|
||||
codec.queueInputBuffer(inIndex, 0, sampleSize, presentationTimeUs, 0)
|
||||
extractor.advance()
|
||||
}
|
||||
}
|
||||
|
||||
// Dequeue output
|
||||
var outIndex = codec.dequeueOutputBuffer(outInfo, 10_000)
|
||||
while (outIndex >= 0) {
|
||||
val outBuf = codec.getOutputBuffer(outIndex)
|
||||
if (outBuf != null && outInfo.size > 0) {
|
||||
outBuf.order(ByteOrder.LITTLE_ENDIAN)
|
||||
val shortCount = outInfo.size / 2
|
||||
val shorts = ShortArray(shortCount)
|
||||
outBuf.asShortBuffer().get(shorts)
|
||||
|
||||
// Map this buffer to bins using timestamp range
|
||||
val startUs = outInfo.presentationTimeUs
|
||||
val endUs = startUs + bufferDurationUs(format, outInfo.size)
|
||||
val startBin = binForTime(startUs, durationUs, desiredBins)
|
||||
val endBin = binForTime(endUs, durationUs, desiredBins).coerceAtMost(desiredBins - 1)
|
||||
|
||||
var idx = 0
|
||||
for (bin in startBin..endBin) {
|
||||
// aggregate portion of buffer to this bin
|
||||
val window = shorts.size / max(1, (endBin - startBin + 1))
|
||||
val begin = idx
|
||||
val finish = min(shorts.size, idx + window)
|
||||
var acc = 0.0
|
||||
var cnt = 0
|
||||
for (i in begin until finish) {
|
||||
acc += abs(shorts[i].toInt())
|
||||
cnt += 1
|
||||
}
|
||||
val avg = if (cnt > 0) (acc / cnt) else 0.0
|
||||
val norm = (avg / 32768.0).coerceIn(0.0, 1.0).toFloat()
|
||||
bins[bin] = max(bins[bin], norm)
|
||||
counts[bin] += 1
|
||||
idx += window
|
||||
}
|
||||
}
|
||||
codec.releaseOutputBuffer(outIndex, false)
|
||||
outIndex = codec.dequeueOutputBuffer(outInfo, 0)
|
||||
}
|
||||
|
||||
if (outInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||||
sawEOS = true
|
||||
}
|
||||
}
|
||||
|
||||
codec.stop()
|
||||
codec.release()
|
||||
extractor.release()
|
||||
|
||||
// Smooth + normalize
|
||||
var maxVal = 0f
|
||||
for (i in bins.indices) {
|
||||
if (counts[i] == 0) continue
|
||||
maxVal = max(maxVal, bins[i])
|
||||
}
|
||||
if (maxVal <= 0f) maxVal = 1f
|
||||
for (i in bins.indices) {
|
||||
bins[i] = (bins[i] / maxVal).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
return bins
|
||||
}
|
||||
|
||||
private fun bufferDurationUs(format: MediaFormat, bytes: Int): Long {
|
||||
return try {
|
||||
val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
val channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
val samples = bytes / 2 / max(1, channels)
|
||||
(samples * 1_000_000L) / max(1, sampleRate)
|
||||
} catch (e: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
private fun binForTime(presentationUs: Long, durationUs: Long, bins: Int): Int {
|
||||
if (durationUs <= 0L) return 0
|
||||
val frac = presentationUs.toDouble() / durationUs.toDouble()
|
||||
return (frac * bins).toInt().coerceIn(0, bins - 1)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ object Geohash {
|
||||
private val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz".toCharArray()
|
||||
private val charToValue: Map<Char, Int> = base32Chars.withIndex().associate { it.value to it.index }
|
||||
|
||||
data class Bounds(val latMin: Double, val latMax: Double, val lonMin: Double, val lonMax: Double)
|
||||
|
||||
/**
|
||||
* Encodes the provided coordinates into a geohash string.
|
||||
* @param latitude Latitude in degrees (-90...90)
|
||||
@@ -69,14 +71,24 @@ object Geohash {
|
||||
* @return Pair(latitude, longitude)
|
||||
*/
|
||||
fun decodeToCenter(geohash: String): Pair<Double, Double> {
|
||||
if (geohash.isEmpty()) return 0.0 to 0.0
|
||||
val b = decodeToBounds(geohash)
|
||||
val latCenter = (b.latMin + b.latMax) / 2
|
||||
val lonCenter = (b.lonMin + b.lonMax) / 2
|
||||
return latCenter to lonCenter
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a geohash string to bounding box (lat/lon min/max).
|
||||
*/
|
||||
fun decodeToBounds(geohash: String): Bounds {
|
||||
if (geohash.isEmpty()) return Bounds(0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
var latInterval = -90.0 to 90.0
|
||||
var lonInterval = -180.0 to 180.0
|
||||
var isEven = true
|
||||
|
||||
geohash.lowercase().forEach { ch ->
|
||||
val cd = charToValue[ch] ?: return 0.0 to 0.0
|
||||
val cd = charToValue[ch] ?: return Bounds(0.0, 0.0, 0.0, 0.0)
|
||||
for (mask in intArrayOf(16, 8, 4, 2, 1)) {
|
||||
if (isEven) {
|
||||
val mid = (lonInterval.first + lonInterval.second) / 2
|
||||
@@ -96,9 +108,11 @@ object Geohash {
|
||||
isEven = !isEven
|
||||
}
|
||||
}
|
||||
|
||||
val latCenter = (latInterval.first + latInterval.second) / 2
|
||||
val lonCenter = (lonInterval.first + lonInterval.second) / 2
|
||||
return latCenter to lonCenter
|
||||
return Bounds(
|
||||
latMin = minOf(latInterval.first, latInterval.second),
|
||||
latMax = maxOf(latInterval.first, latInterval.second),
|
||||
lonMin = minOf(lonInterval.first, lonInterval.second),
|
||||
lonMax = maxOf(lonInterval.first, lonInterval.second)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Stores a user-maintained list of bookmarked geohash channels.
|
||||
* - Persistence: SharedPreferences (JSON string array)
|
||||
* - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
|
||||
*/
|
||||
class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "GeohashBookmarksStore"
|
||||
private const val STORE_KEY = "locationChannel.bookmarks"
|
||||
private const val NAMES_STORE_KEY = "locationChannel.bookmarkNames"
|
||||
|
||||
@Volatile private var INSTANCE: GeohashBookmarksStore? = null
|
||||
fun getInstance(context: Context): GeohashBookmarksStore {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: GeohashBookmarksStore(context.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
|
||||
private val allowedChars = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
|
||||
fun normalize(raw: String): String {
|
||||
return raw.trim().lowercase(Locale.US)
|
||||
.replace("#", "")
|
||||
.filter { allowedChars.contains(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private val gson = Gson()
|
||||
private val prefs = context.getSharedPreferences("geohash_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
private val membership = mutableSetOf<String>()
|
||||
|
||||
private val _bookmarks = MutableLiveData<List<String>>(emptyList())
|
||||
val bookmarks: LiveData<List<String>> = _bookmarks
|
||||
|
||||
private val _bookmarkNames = MutableLiveData<Map<String, String>>(emptyMap())
|
||||
val bookmarkNames: LiveData<Map<String, String>> = _bookmarkNames
|
||||
|
||||
// For throttling / preventing duplicate geocode lookups
|
||||
private val resolving = mutableSetOf<String>()
|
||||
|
||||
init { load() }
|
||||
|
||||
fun isBookmarked(geohash: String): Boolean = membership.contains(normalize(geohash))
|
||||
|
||||
fun toggle(geohash: String) {
|
||||
val gh = normalize(geohash)
|
||||
if (membership.contains(gh)) remove(gh) else add(gh)
|
||||
}
|
||||
|
||||
fun add(geohash: String) {
|
||||
val gh = normalize(geohash)
|
||||
if (gh.isEmpty() || membership.contains(gh)) return
|
||||
membership.add(gh)
|
||||
val updated = listOf(gh) + (_bookmarks.value ?: emptyList())
|
||||
_bookmarks.postValue(updated)
|
||||
persist(updated)
|
||||
// Resolve friendly name asynchronously
|
||||
resolveNameIfNeeded(gh)
|
||||
}
|
||||
|
||||
fun remove(geohash: String) {
|
||||
val gh = normalize(geohash)
|
||||
if (!membership.contains(gh)) return
|
||||
membership.remove(gh)
|
||||
val updated = (_bookmarks.value ?: emptyList()).filterNot { it == gh }
|
||||
_bookmarks.postValue(updated)
|
||||
// Remove stored name to avoid stale cache growth
|
||||
val names = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
|
||||
if (names.remove(gh) != null) {
|
||||
_bookmarkNames.postValue(names)
|
||||
persistNames(names)
|
||||
}
|
||||
persist(updated)
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private fun load() {
|
||||
try {
|
||||
val arrJson = prefs.getString(STORE_KEY, null)
|
||||
if (!arrJson.isNullOrEmpty()) {
|
||||
val listType = object : TypeToken<List<String>>() {}.type
|
||||
val arr = gson.fromJson<List<String>>(arrJson, listType)
|
||||
val seen = mutableSetOf<String>()
|
||||
val ordered = mutableListOf<String>()
|
||||
arr.forEach { raw ->
|
||||
val gh = normalize(raw)
|
||||
if (gh.isNotEmpty() && !seen.contains(gh)) {
|
||||
seen.add(gh)
|
||||
ordered.add(gh)
|
||||
}
|
||||
}
|
||||
membership.clear(); membership.addAll(seen)
|
||||
_bookmarks.postValue(ordered)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load bookmarks: ${e.message}")
|
||||
}
|
||||
try {
|
||||
val namesJson = prefs.getString(NAMES_STORE_KEY, null)
|
||||
if (!namesJson.isNullOrEmpty()) {
|
||||
val mapType = object : TypeToken<Map<String, String>>() {}.type
|
||||
val dict = gson.fromJson<Map<String, String>>(namesJson, mapType)
|
||||
_bookmarkNames.postValue(dict)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load bookmark names: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
try {
|
||||
val json = gson.toJson(_bookmarks.value ?: emptyList<String>())
|
||||
prefs.edit().putString(STORE_KEY, json).apply()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun persistNames() {
|
||||
try {
|
||||
val json = gson.toJson(_bookmarkNames.value ?: emptyMap<String, String>())
|
||||
prefs.edit().putString(NAMES_STORE_KEY, json).apply()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
// MARK: - Destructive Reset
|
||||
|
||||
fun clearAll() {
|
||||
try {
|
||||
membership.clear()
|
||||
_bookmarks.postValue(emptyList())
|
||||
_bookmarkNames.postValue(emptyMap())
|
||||
prefs.edit()
|
||||
.remove(STORE_KEY)
|
||||
.remove(NAMES_STORE_KEY)
|
||||
.apply()
|
||||
// Clear any in-flight resolutions to avoid repopulating
|
||||
resolving.clear()
|
||||
Log.i(TAG, "Cleared all geohash bookmarks and names")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear geohash bookmarks: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Friendly Name Resolution
|
||||
|
||||
fun resolveNameIfNeeded(geohash: String) {
|
||||
val gh = normalize(geohash)
|
||||
if (gh.isEmpty()) return
|
||||
if (_bookmarkNames.value?.containsKey(gh) == true) return
|
||||
if (resolving.contains(gh)) return
|
||||
if (!Geocoder.isPresent()) return
|
||||
|
||||
resolving.add(gh)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val geocoder = Geocoder(context, Locale.getDefault())
|
||||
val name: String? = if (gh.length <= 2) {
|
||||
// Composite admin name from multiple points
|
||||
val b = Geohash.decodeToBounds(gh)
|
||||
val points = listOf(
|
||||
Location(LocationManager.GPS_PROVIDER).apply { latitude = (b.latMin + b.latMax) / 2; longitude = (b.lonMin + b.lonMax) / 2 },
|
||||
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMin; longitude = b.lonMin },
|
||||
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMin; longitude = b.lonMax },
|
||||
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMax; longitude = b.lonMin },
|
||||
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMax; longitude = b.lonMax }
|
||||
)
|
||||
val admins = linkedSetOf<String>()
|
||||
for (loc in points) {
|
||||
try {
|
||||
@Suppress("DEPRECATION")
|
||||
val list = geocoder.getFromLocation(loc.latitude, loc.longitude, 1)
|
||||
val a = list?.firstOrNull()
|
||||
val admin = a?.adminArea?.takeIf { !it.isNullOrEmpty() }
|
||||
val country = a?.countryName?.takeIf { !it.isNullOrEmpty() }
|
||||
if (admin != null) admins.add(admin)
|
||||
else if (country != null) admins.add(country)
|
||||
} catch (_: Exception) {}
|
||||
if (admins.size >= 2) break
|
||||
}
|
||||
when (admins.size) {
|
||||
0 -> null
|
||||
1 -> admins.first()
|
||||
else -> admins.elementAt(0) + " and " + admins.elementAt(1)
|
||||
}
|
||||
} else {
|
||||
val center = Geohash.decodeToCenter(gh)
|
||||
@Suppress("DEPRECATION")
|
||||
val list = geocoder.getFromLocation(center.first, center.second, 1)
|
||||
val a = list?.firstOrNull()
|
||||
pickNameForLength(gh.length, a)
|
||||
}
|
||||
|
||||
if (!name.isNullOrEmpty()) {
|
||||
val current = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
|
||||
current[gh] = name
|
||||
_bookmarkNames.postValue(current)
|
||||
persistNames(current)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Name resolution failed for #$gh: ${e.message}")
|
||||
} finally {
|
||||
resolving.remove(gh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickNameForLength(len: Int, address: android.location.Address?): String? {
|
||||
if (address == null) return null
|
||||
return when (len) {
|
||||
in 0..2 -> address.adminArea ?: address.countryName
|
||||
in 3..4 -> address.adminArea ?: address.subAdminArea ?: address.countryName
|
||||
5 -> address.locality ?: address.subAdminArea ?: address.adminArea
|
||||
in 6..7 -> address.subLocality ?: address.locality ?: address.adminArea
|
||||
else -> address.subLocality ?: address.locality ?: address.adminArea ?: address.countryName
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist(list: List<String>) {
|
||||
try {
|
||||
val json = gson.toJson(list)
|
||||
prefs.edit().putString(STORE_KEY, json).apply()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun persistNames(map: Map<String, String>) {
|
||||
try {
|
||||
val json = gson.toJson(map)
|
||||
prefs.edit().putString(NAMES_STORE_KEY, json).apply()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
@@ -13,7 +12,6 @@ import android.util.Log
|
||||
*
|
||||
* Handles:
|
||||
* - Static identity key persistence across app sessions
|
||||
* - Peer ID rotation timing (5-15 minute random intervals)
|
||||
* - Secure storage using Android EncryptedSharedPreferences
|
||||
* - Fingerprint calculation and identity validation
|
||||
*/
|
||||
@@ -26,16 +24,9 @@ class SecureIdentityStateManager(private val context: Context) {
|
||||
private const val KEY_STATIC_PUBLIC_KEY = "static_public_key"
|
||||
private const val KEY_SIGNING_PRIVATE_KEY = "signing_private_key"
|
||||
private const val KEY_SIGNING_PUBLIC_KEY = "signing_public_key"
|
||||
private const val KEY_LAST_ROTATION = "last_rotation"
|
||||
private const val KEY_NEXT_ROTATION_INTERVAL = "next_rotation_interval"
|
||||
|
||||
// Rotation intervals (same as iOS)
|
||||
private const val MIN_ROTATION_INTERVAL = 5 * 60 * 1000L // 5 minutes
|
||||
private const val MAX_ROTATION_INTERVAL = 15 * 60 * 1000L // 15 minutes
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences
|
||||
private val random = SecureRandom()
|
||||
|
||||
init {
|
||||
// Create master key for encryption
|
||||
@@ -188,70 +179,9 @@ class SecureIdentityStateManager(private val context: Context) {
|
||||
return fingerprint.matches(Regex("^[a-fA-F0-9]{64}$"))
|
||||
}
|
||||
|
||||
// MARK: - Peer ID Rotation Management
|
||||
|
||||
/**
|
||||
* Check if peer ID should be rotated based on random interval
|
||||
*/
|
||||
fun shouldRotatePeerID(): Boolean {
|
||||
val lastRotation = prefs.getLong(KEY_LAST_ROTATION, 0L)
|
||||
val nextInterval = prefs.getLong(KEY_NEXT_ROTATION_INTERVAL, 0L)
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
if (lastRotation == 0L || nextInterval == 0L) {
|
||||
// First run or missing data - schedule next rotation and don't rotate now
|
||||
scheduleNextRotation()
|
||||
return false
|
||||
}
|
||||
|
||||
val shouldRotate = (now - lastRotation) >= nextInterval
|
||||
if (shouldRotate) {
|
||||
Log.d(TAG, "Peer ID rotation due: ${(now - lastRotation) / 1000}s since last rotation")
|
||||
}
|
||||
|
||||
return shouldRotate
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark rotation as completed and schedule next one
|
||||
*/
|
||||
fun markRotationCompleted() {
|
||||
val now = System.currentTimeMillis()
|
||||
prefs.edit()
|
||||
.putLong(KEY_LAST_ROTATION, now)
|
||||
.apply()
|
||||
|
||||
scheduleNextRotation()
|
||||
|
||||
Log.d(TAG, "Peer ID rotation marked as completed")
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the next rotation with random interval (5-15 minutes)
|
||||
*/
|
||||
private fun scheduleNextRotation() {
|
||||
val nextInterval = MIN_ROTATION_INTERVAL + random.nextLong(MAX_ROTATION_INTERVAL - MIN_ROTATION_INTERVAL)
|
||||
|
||||
prefs.edit()
|
||||
.putLong(KEY_NEXT_ROTATION_INTERVAL, nextInterval)
|
||||
.apply()
|
||||
|
||||
Log.d(TAG, "Next peer ID rotation scheduled in ${nextInterval / 60000} minutes")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time until next rotation (for debugging)
|
||||
*/
|
||||
fun getTimeUntilNextRotation(): Long {
|
||||
val lastRotation = prefs.getLong(KEY_LAST_ROTATION, 0L)
|
||||
val nextInterval = prefs.getLong(KEY_NEXT_ROTATION_INTERVAL, 0L)
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
if (lastRotation == 0L || nextInterval == 0L) return -1
|
||||
|
||||
val elapsed = now - lastRotation
|
||||
return maxOf(0L, nextInterval - elapsed)
|
||||
}
|
||||
// MARK: - Peer ID Rotation Management (removed)
|
||||
// Android now derives peer ID from the persisted Noise identity fingerprint.
|
||||
// No timed peer ID rotation is performed here.
|
||||
|
||||
// MARK: - Identity Validation
|
||||
|
||||
@@ -305,14 +235,6 @@ class SecureIdentityStateManager(private val context: Context) {
|
||||
appendLine("Has identity: $hasIdentity")
|
||||
|
||||
if (hasIdentity) {
|
||||
val lastRotation = prefs.getLong(KEY_LAST_ROTATION, 0L)
|
||||
val nextInterval = prefs.getLong(KEY_NEXT_ROTATION_INTERVAL, 0L)
|
||||
val timeUntilNext = getTimeUntilNextRotation()
|
||||
|
||||
appendLine("Last rotation: ${if (lastRotation > 0) "${(System.currentTimeMillis() - lastRotation) / 1000}s ago" else "never"}")
|
||||
appendLine("Next rotation in: ${if (timeUntilNext >= 0) "${timeUntilNext / 1000}s" else "not scheduled"}")
|
||||
appendLine("Rotation interval: ${nextInterval / 1000}s")
|
||||
|
||||
try {
|
||||
val keyPair = loadStaticKey()
|
||||
if (keyPair != null) {
|
||||
|
||||
@@ -29,7 +29,7 @@ class BluetoothConnectionManager(
|
||||
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
|
||||
|
||||
// Power management
|
||||
private val powerManager = PowerManager(context)
|
||||
private val powerManager = PowerManager(context.applicationContext)
|
||||
|
||||
// Coroutines
|
||||
private val connectionScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
@@ -42,7 +42,7 @@ class BluetoothConnectionManager(
|
||||
// Delegate for component managers to call back to main manager
|
||||
private val componentDelegate = object : BluetoothConnectionManagerDelegate {
|
||||
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) {
|
||||
Log.d(TAG, "onPacketReceived: Packet received from ${device?.address} ($peerID)")
|
||||
Log.d(TAG, "onPacketReceived: Packet received from ${'$'}{device?.address} ($peerID)")
|
||||
device?.let { bluetoothDevice ->
|
||||
// Get current RSSI for this device and update if available
|
||||
val currentRSSI = connectionTracker.getBestRSSI(bluetoothDevice.address)
|
||||
@@ -195,7 +195,7 @@ class BluetoothConnectionManager(
|
||||
return true
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start Bluetooth services: ${e.message}")
|
||||
Log.e(TAG, "Failed to start Bluetooth services: ${'$'}{e.message}")
|
||||
isActive = false
|
||||
return false
|
||||
}
|
||||
@@ -248,11 +248,31 @@ class BluetoothConnectionManager(
|
||||
)
|
||||
}
|
||||
|
||||
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
fun cancelTransfer(transferId: String): Boolean {
|
||||
return packetBroadcaster.cancelTransfer(transferId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a packet directly to a specific peer, without broadcasting to others.
|
||||
*/
|
||||
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
|
||||
if (!isActive) return false
|
||||
return packetBroadcaster.sendToPeer(
|
||||
return packetBroadcaster.sendPacketToPeer(
|
||||
RoutedPacket(packet),
|
||||
peerID,
|
||||
serverManager.getGattServer(),
|
||||
serverManager.getCharacteristic()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overload that preserves routing metadata when available.
|
||||
*/
|
||||
fun sendPacketToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
if (!isActive) return false
|
||||
return packetBroadcaster.sendPacketToPeer(
|
||||
routed,
|
||||
peerID,
|
||||
serverManager.getGattServer(),
|
||||
serverManager.getCharacteristic()
|
||||
)
|
||||
@@ -321,11 +341,11 @@ class BluetoothConnectionManager(
|
||||
fun getDebugInfo(): String {
|
||||
return buildString {
|
||||
appendLine("=== Bluetooth Connection Manager ===")
|
||||
appendLine("Bluetooth MAC Address: ${bluetoothAdapter?.address}")
|
||||
appendLine("Active: $isActive")
|
||||
appendLine("Bluetooth Enabled: ${bluetoothAdapter?.isEnabled}")
|
||||
appendLine("Has Permissions: ${permissionManager.hasBluetoothPermissions()}")
|
||||
appendLine("GATT Server Active: ${serverManager.getGattServer() != null}")
|
||||
appendLine("Bluetooth MAC Address: ${'$'}{bluetoothAdapter?.address}")
|
||||
appendLine("Active: ${'$'}isActive")
|
||||
appendLine("Bluetooth Enabled: ${'$'}{bluetoothAdapter?.isEnabled}")
|
||||
appendLine("Has Permissions: ${'$'}{permissionManager.hasBluetoothPermissions()}")
|
||||
appendLine("GATT Server Active: ${'$'}{serverManager.getGattServer() != null}")
|
||||
appendLine()
|
||||
appendLine(powerManager.getPowerInfo())
|
||||
appendLine()
|
||||
@@ -336,7 +356,7 @@ class BluetoothConnectionManager(
|
||||
// MARK: - PowerManagerDelegate Implementation
|
||||
|
||||
override fun onPowerModeChanged(newMode: PowerManager.PowerMode) {
|
||||
Log.i(TAG, "Power mode changed to: $newMode")
|
||||
Log.i(TAG, "Power mode changed to: ${'$'}newMode")
|
||||
|
||||
connectionScope.launch {
|
||||
// Avoid rapid scan restarts by checking if we need to change scan behavior
|
||||
@@ -353,7 +373,7 @@ class BluetoothConnectionManager(
|
||||
// Only restart scanning if the duty cycle behavior changed
|
||||
val nowUsingDutyCycle = powerManager.shouldUseDutyCycle()
|
||||
if (wasUsingDutyCycle != nowUsingDutyCycle) {
|
||||
Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan")
|
||||
Log.d(TAG, "Duty cycle behavior changed (${'$'}wasUsingDutyCycle -> ${'$'}nowUsingDutyCycle), restarting scan")
|
||||
val clientEnabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattClientEnabled.value } catch (_: Exception) { true }
|
||||
if (clientEnabled) {
|
||||
clientManager.restartScanning()
|
||||
|
||||
@@ -327,8 +327,31 @@ class BluetoothGattServerManager(
|
||||
private fun startAdvertising() {
|
||||
// Respect debug setting
|
||||
val enabled = try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().gattServerEnabled.value } catch (_: Exception) { true }
|
||||
if (!permissionManager.hasBluetoothPermissions() || bleAdvertiser == null || !isActive || bluetoothAdapter == null || !bluetoothAdapter.isMultipleAdvertisementSupported() || !enabled) {
|
||||
throw Exception("Missing Bluetooth permissions or BLE advertiser not available")
|
||||
|
||||
// Guard conditions – never throw here to avoid crashing the app from a background coroutine
|
||||
if (!permissionManager.hasBluetoothPermissions()) {
|
||||
Log.w(TAG, "Not starting advertising: missing Bluetooth permissions")
|
||||
return
|
||||
}
|
||||
if (bluetoothAdapter == null) {
|
||||
Log.w(TAG, "Not starting advertising: bluetoothAdapter is null")
|
||||
return
|
||||
}
|
||||
if (!isActive) {
|
||||
Log.d(TAG, "Not starting advertising: manager not active")
|
||||
return
|
||||
}
|
||||
if (!enabled) {
|
||||
Log.i(TAG, "Not starting advertising: GATT Server disabled via debug settings")
|
||||
return
|
||||
}
|
||||
if (bleAdvertiser == null) {
|
||||
Log.w(TAG, "Not starting advertising: BLE advertiser not available on this device")
|
||||
return
|
||||
}
|
||||
if (!bluetoothAdapter.isMultipleAdvertisementSupported) {
|
||||
Log.w(TAG, "Not starting advertising: multiple advertisement not supported on this device")
|
||||
return
|
||||
}
|
||||
|
||||
val settings = powerManager.getAdvertiseSettings()
|
||||
@@ -341,7 +364,10 @@ class BluetoothGattServerManager(
|
||||
|
||||
advertiseCallback = object : AdvertiseCallback() {
|
||||
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
||||
Log.i(TAG, "Advertising started (power mode: ${powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0]})")
|
||||
val mode = try {
|
||||
powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0]
|
||||
} catch (_: Exception) { "unknown" }
|
||||
Log.i(TAG, "Advertising started (power mode: $mode)")
|
||||
}
|
||||
|
||||
override fun onStartFailure(errorCode: Int) {
|
||||
@@ -351,6 +377,8 @@ class BluetoothGattServerManager(
|
||||
|
||||
try {
|
||||
bleAdvertiser.startAdvertising(settings, data, advertiseCallback)
|
||||
} catch (se: SecurityException) {
|
||||
Log.e(TAG, "SecurityException starting advertising (missing permission?): ${se.message}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception starting advertising: ${e.message}")
|
||||
}
|
||||
@@ -363,7 +391,7 @@ class BluetoothGattServerManager(
|
||||
private fun stopAdvertising() {
|
||||
if (!permissionManager.hasBluetoothPermissions() || bleAdvertiser == null) return
|
||||
try {
|
||||
advertiseCallback?.let { bleAdvertiser.stopAdvertising(it) }
|
||||
advertiseCallback?.let { cb -> bleAdvertiser.stopAdvertising(cb) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error stopping advertising: ${e.message}")
|
||||
}
|
||||
@@ -386,4 +414,4 @@ class BluetoothGattServerManager(
|
||||
startAdvertising()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import com.bitchat.android.model.RequestSyncPacket
|
||||
import com.bitchat.android.sync.GossipSyncManager
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
@@ -37,18 +39,19 @@ class BluetoothMeshService(private val context: Context) {
|
||||
private const val MAX_TTL: UByte = 7u
|
||||
}
|
||||
|
||||
// My peer identification - same format as iOS
|
||||
val myPeerID: String = generateCompatiblePeerID()
|
||||
|
||||
// Core components - each handling specific responsibilities
|
||||
private val encryptionService = EncryptionService(context)
|
||||
|
||||
// My peer identification - derived from persisted Noise identity fingerprint (first 16 hex chars)
|
||||
val myPeerID: String = encryptionService.getIdentityFingerprint().take(16)
|
||||
private val peerManager = PeerManager()
|
||||
private val fragmentManager = FragmentManager()
|
||||
private val securityManager = SecurityManager(encryptionService, myPeerID)
|
||||
private val storeForwardManager = StoreForwardManager()
|
||||
private val messageHandler = MessageHandler(myPeerID)
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
|
||||
private val packetProcessor = PacketProcessor(myPeerID)
|
||||
private lateinit var gossipSyncManager: GossipSyncManager
|
||||
|
||||
// Service state management
|
||||
private var isActive = false
|
||||
@@ -63,6 +66,38 @@ class BluetoothMeshService(private val context: Context) {
|
||||
setupDelegates()
|
||||
messageHandler.packetProcessor = packetProcessor
|
||||
//startPeriodicDebugLogging()
|
||||
|
||||
// Initialize sync manager (needs serviceScope)
|
||||
gossipSyncManager = GossipSyncManager(
|
||||
myPeerID = myPeerID,
|
||||
scope = serviceScope,
|
||||
configProvider = object : GossipSyncManager.ConfigProvider {
|
||||
override fun seenCapacity(): Int = try {
|
||||
com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(500)
|
||||
} catch (_: Exception) { 500 }
|
||||
|
||||
override fun gcsMaxBytes(): Int = try {
|
||||
com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsMaxFilterBytes(400)
|
||||
} catch (_: Exception) { 400 }
|
||||
|
||||
override fun gcsTargetFpr(): Double = try {
|
||||
com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsFprPercent(1.0) / 100.0
|
||||
} catch (_: Exception) { 0.01 }
|
||||
}
|
||||
)
|
||||
|
||||
// Wire sync manager delegate
|
||||
gossipSyncManager.delegate = object : GossipSyncManager.Delegate {
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
connectionManager.broadcastPacket(RoutedPacket(packet))
|
||||
}
|
||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
|
||||
connectionManager.sendPacketToPeer(peerID, packet)
|
||||
}
|
||||
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
|
||||
return signPacketBeforeBroadcast(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +113,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
Log.d(TAG, "=== PERIODIC DEBUG STATUS ===\n$debugInfo\n=== END DEBUG STATUS ===")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in periodic debug logging: ${e.message}")
|
||||
Log.e(TAG, "Error in periodic debug logging: ${'$'}{e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,7 +129,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
delay(30000) // 30 seconds
|
||||
sendBroadcastAnnounce()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in periodic broadcast announce: ${e.message}")
|
||||
Log.e(TAG, "Error in periodic broadcast announce: ${'$'}{e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +148,16 @@ class BluetoothMeshService(private val context: Context) {
|
||||
override fun onPeerListUpdated(peerIDs: List<String>) {
|
||||
delegate?.didUpdatePeerList(peerIDs)
|
||||
}
|
||||
override fun onPeerRemoved(peerID: String) {
|
||||
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
|
||||
// Also drop any Noise session state for this peer when they go offline
|
||||
try {
|
||||
encryptionService.removePeer(peerID)
|
||||
Log.d(TAG, "Removed Noise session for offline peer $peerID")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to remove Noise session for $peerID: ${'$'}{e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SecurityManager delegate for key exchange notifications
|
||||
@@ -142,7 +187,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
// Sign the handshake response
|
||||
val signedPacket = signPacketBeforeBroadcast(responsePacket)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "Sent Noise handshake response to $peerID (${response.size} bytes)")
|
||||
Log.d(TAG, "Sent Noise handshake response to $peerID (${ '$' }{response.size} bytes)")
|
||||
}
|
||||
|
||||
override fun getPeerInfo(peerID: String): PeerInfo? {
|
||||
@@ -256,13 +301,13 @@ class BluetoothMeshService(private val context: Context) {
|
||||
// Sign the handshake packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)")
|
||||
Log.d(TAG, "Initiated Noise handshake with $peerID (${ '$' }{handshakeData.size} bytes)")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to generate Noise handshake data for $peerID")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initiate Noise handshake with $peerID: ${e.message}")
|
||||
Log.e(TAG, "Failed to initiate Noise handshake with $peerID: ${'$'}{e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +315,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
return try {
|
||||
encryptionService.processHandshakeMessage(payload, peerID)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to process handshake message from $peerID: ${e.message}")
|
||||
Log.e(TAG, "Failed to process handshake message from $peerID: ${'$'}{e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -278,7 +323,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
override fun updatePeerIDBinding(newPeerID: String, nickname: String,
|
||||
publicKey: ByteArray, previousPeerID: String?) {
|
||||
|
||||
Log.d(TAG, "Updating peer ID binding: $newPeerID (was: $previousPeerID) with nickname: $nickname and public key: ${publicKey.toHexString().take(16)}...")
|
||||
Log.d(TAG, "Updating peer ID binding: $newPeerID (was: $previousPeerID) with nickname: $nickname and public key: ${'$'}{publicKey.toHexString().take(16)}...")
|
||||
// Update peer mapping in the PeerManager for peer ID rotation support
|
||||
peerManager.addOrUpdatePeer(newPeerID, nickname)
|
||||
|
||||
@@ -297,7 +342,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
peerManager.removePeer(oldPeerID)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Updated peer ID binding: $newPeerID (was: $previousPeerID), fingerprint: ${fingerprint.take(16)}...")
|
||||
Log.d(TAG, "Updated peer ID binding: $newPeerID (was: $previousPeerID), fingerprint: ${'$'}{fingerprint.take(16)}...")
|
||||
}
|
||||
|
||||
// Message operations
|
||||
@@ -380,13 +425,26 @@ class BluetoothMeshService(private val context: Context) {
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Schedule initial sync for this new directly connected peer only
|
||||
try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
// Track for sync
|
||||
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleMessage(routed: RoutedPacket) {
|
||||
serviceScope.launch { messageHandler.handleMessage(routed) }
|
||||
// Track broadcast messages for sync
|
||||
try {
|
||||
val pkt = routed.packet
|
||||
val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
if (isBroadcast && pkt.type == MessageType.MESSAGE.value) {
|
||||
gossipSyncManager.onPublicPacketSeen(pkt)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun handleLeave(routed: RoutedPacket) {
|
||||
@@ -394,6 +452,13 @@ class BluetoothMeshService(private val context: Context) {
|
||||
}
|
||||
|
||||
override fun handleFragment(packet: BitchatPacket): BitchatPacket? {
|
||||
// Track broadcast fragments for gossip sync
|
||||
try {
|
||||
val isBroadcast = (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
if (isBroadcast && packet.type == MessageType.FRAGMENT.value) {
|
||||
gossipSyncManager.onPublicPacketSeen(packet)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
return fragmentManager.handleFragment(packet)
|
||||
}
|
||||
|
||||
@@ -409,8 +474,11 @@ class BluetoothMeshService(private val context: Context) {
|
||||
connectionManager.broadcastPacket(routed)
|
||||
}
|
||||
|
||||
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
return connectionManager.sendToPeer(peerID, routed)
|
||||
override fun handleRequestSync(routed: RoutedPacket) {
|
||||
// Decode request and respond with missing packets
|
||||
val fromPeer = routed.peerID ?: return
|
||||
val req = RequestSyncPacket.decode(routed.packet.payload) ?: return
|
||||
gossipSyncManager.handleRequestSync(fromPeer, req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,6 +552,8 @@ class BluetoothMeshService(private val context: Context) {
|
||||
// Start periodic announcements for peer discovery and connectivity
|
||||
sendPeriodicBroadcastAnnounce()
|
||||
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
|
||||
// Start periodic syncs
|
||||
gossipSyncManager.start()
|
||||
} else {
|
||||
Log.e(TAG, "Failed to start Bluetooth services")
|
||||
}
|
||||
@@ -508,6 +578,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
delay(200) // Give leave message time to send
|
||||
|
||||
// Stop all components
|
||||
gossipSyncManager.stop()
|
||||
connectionManager.stopServices()
|
||||
peerManager.shutdown()
|
||||
fragmentManager.shutdown()
|
||||
@@ -541,8 +612,123 @@ class BluetoothMeshService(private val context: Context) {
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
// Track our own broadcast message for sync
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a file over mesh as a broadcast MESSAGE (public mesh timeline/channels).
|
||||
*/
|
||||
fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) {
|
||||
try {
|
||||
Log.d(TAG, "📤 sendFileBroadcast: name=${'$'}{file.fileName}, size=${'$'}{file.fileSize}")
|
||||
val payload = file.encode()
|
||||
if (payload == null) {
|
||||
Log.e(TAG, "❌ Failed to encode file packet in sendFileBroadcast")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "📦 Encoded payload: ${'$'}{payload.size} bytes")
|
||||
serviceScope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = 2u, // FILE_TRANSFER uses v2 for 4-byte payload length to support large files
|
||||
type = MessageType.FILE_TRANSFER.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
// Use a stable transferId based on the file TLV payload for progress tracking
|
||||
val transferId = sha256Hex(payload)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId))
|
||||
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ sendFileBroadcast failed: ${'$'}{e.message}", e)
|
||||
Log.e(TAG, "❌ File: name=${'$'}{file.fileName}, size=${'$'}{file.fileSize}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a file as an encrypted private message using Noise protocol
|
||||
*/
|
||||
fun sendFilePrivate(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) {
|
||||
try {
|
||||
Log.d(TAG, "📤 sendFilePrivate (ENCRYPTED): to=$recipientPeerID, name=${'$'}{file.fileName}, size=${'$'}{file.fileSize}")
|
||||
|
||||
serviceScope.launch {
|
||||
// Check if we have an established Noise session
|
||||
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
|
||||
try {
|
||||
// Encode the file packet as TLV
|
||||
val filePayload = file.encode()
|
||||
if (filePayload == null) {
|
||||
Log.e(TAG, "❌ Failed to encode file packet for private send")
|
||||
return@launch
|
||||
}
|
||||
Log.d(TAG, "📦 Encoded file TLV: ${'$'}{filePayload.size} bytes")
|
||||
|
||||
// Create NoisePayload wrapper (type byte + file TLV data) - same as iOS
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload(
|
||||
type = com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER,
|
||||
data = filePayload
|
||||
)
|
||||
|
||||
// Encrypt the payload using Noise
|
||||
val encrypted = encryptionService.encrypt(noisePayload.encode(), recipientPeerID)
|
||||
if (encrypted == null) {
|
||||
Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID")
|
||||
return@launch
|
||||
}
|
||||
Log.d(TAG, "🔐 Encrypted file payload: ${'$'}{encrypted.size} bytes")
|
||||
|
||||
// Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!)
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = encrypted,
|
||||
signature = null,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
// Sign and send the encrypted packet
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
// Use a stable transferId based on the unencrypted file TLV payload for progress tracking
|
||||
val transferId = sha256Hex(filePayload)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signed, transferId = transferId))
|
||||
Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID: ${'$'}{e.message}", e)
|
||||
}
|
||||
} else {
|
||||
// No session - initiate handshake but don't queue file
|
||||
Log.w(TAG, "⚠️ No Noise session with $recipientPeerID for file transfer, initiating handshake")
|
||||
messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ sendFilePrivate failed: ${'$'}{e.message}", e)
|
||||
Log.e(TAG, "❌ File: to=$recipientPeerID, name=${'$'}{file.fileName}, size=${'$'}{file.fileSize}")
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelFileTransfer(transferId: String): Boolean {
|
||||
return connectionManager.cancelTransfer(transferId)
|
||||
}
|
||||
|
||||
// Local helper to hash payloads to a stable hex ID for progress mapping
|
||||
private fun sha256Hex(bytes: ByteArray): String = try {
|
||||
val md = java.security.MessageDigest.getInstance("SHA-256")
|
||||
md.update(bytes)
|
||||
md.digest().joinToString("") { "%02x".format(it) }
|
||||
} catch (_: Exception) { bytes.size.toString(16) }
|
||||
|
||||
/**
|
||||
* Send private message - SIMPLIFIED iOS-compatible version
|
||||
@@ -555,7 +741,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
serviceScope.launch {
|
||||
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
|
||||
|
||||
Log.d(TAG, "📨 Sending PM to $recipientPeerID: ${content.take(30)}...")
|
||||
Log.d(TAG, "📨 Sending PM to $recipientPeerID: ${'$'}{content.take(30)}...")
|
||||
|
||||
// Check if we have an established Noise session
|
||||
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
|
||||
@@ -596,14 +782,14 @@ class BluetoothMeshService(private val context: Context) {
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)")
|
||||
Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${ '$' }{encrypted.size} bytes)")
|
||||
|
||||
// FIXED: Don't send didReceiveMessage for our own sent messages
|
||||
// This was causing self-notifications - iOS doesn't do this
|
||||
// The UI handles showing sent messages through its own message sending logic
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to encrypt private message for $recipientPeerID: ${e.message}")
|
||||
Log.e(TAG, "Failed to encrypt private message for $recipientPeerID: ${'$'}{e.message}")
|
||||
}
|
||||
} else {
|
||||
// Fire and forget - initiate handshake but don't queue exactly like iOS
|
||||
@@ -663,7 +849,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}")
|
||||
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${'$'}{e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -725,7 +911,9 @@ class BluetoothMeshService(private val context: Context) {
|
||||
} ?: announcePacket
|
||||
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "Sent iOS-compatible signed TLV announce (${tlvPayload.size} bytes)")
|
||||
Log.d(TAG, "Sent iOS-compatible signed TLV announce (${ '$' }{tlvPayload.size} bytes)")
|
||||
// Track announce for sync
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,7 +975,10 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
peerManager.markPeerAsAnnouncedTo(peerID)
|
||||
Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)")
|
||||
Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${ '$' }{tlvPayload.size} bytes)")
|
||||
|
||||
// Track announce for sync
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -950,15 +1141,6 @@ class BluetoothMeshService(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate peer ID compatible with iOS - exactly 8 bytes (16 hex characters)
|
||||
*/
|
||||
private fun generateCompatiblePeerID(): String {
|
||||
val randomBytes = ByteArray(8) // 8 bytes = 16 hex characters (like iOS)
|
||||
Random.nextBytes(randomBytes)
|
||||
return randomBytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string peer ID to binary data (8 bytes) - exactly same as iOS
|
||||
*/
|
||||
@@ -995,7 +1177,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
// Exclude first (sender) and last (recipient); only intermediates
|
||||
val intermediates = path.subList(1, path.size - 1)
|
||||
val hopsBytes = intermediates.map { hexStringToByteArray(it) }
|
||||
Log.d(TAG, "✅ Signed packet type ${packet.type} (route ${hopsBytes.size} hops: $intermediates)")
|
||||
Log.d(TAG, "✅ Signed packet type ${'$'}{packet.type} (route ${'$'}{hopsBytes.size} hops: $intermediates)")
|
||||
packet.copy(route = hopsBytes)
|
||||
} else packet.copy(route = null)
|
||||
} else packet
|
||||
@@ -1004,21 +1186,21 @@ class BluetoothMeshService(private val context: Context) {
|
||||
// Get the canonical packet data for signing (without signature)
|
||||
val packetDataForSigning = withRoute.toBinaryDataForSigning()
|
||||
if (packetDataForSigning == null) {
|
||||
Log.w(TAG, "Failed to encode packet type ${packet.type} for signing, sending unsigned")
|
||||
Log.w(TAG, "Failed to encode packet type ${'$'}{packet.type} for signing, sending unsigned")
|
||||
return withRoute
|
||||
}
|
||||
|
||||
// Sign the packet data using our signing key
|
||||
val signature = encryptionService.signData(packetDataForSigning)
|
||||
if (signature != null) {
|
||||
Log.d(TAG, "✅ Signed packet type ${packet.type} (signature ${signature.size} bytes)")
|
||||
Log.d(TAG, "✅ Signed packet type ${'$'}{packet.type} (signature ${'$'}{signature.size} bytes)")
|
||||
withRoute.copy(signature = signature)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to sign packet type ${packet.type}, sending unsigned")
|
||||
Log.w(TAG, "Failed to sign packet type ${'$'}{packet.type}, sending unsigned")
|
||||
withRoute
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error signing packet type ${packet.type}: ${e.message}, sending unsigned")
|
||||
Log.w(TAG, "Error signing packet type ${'$'}{packet.type}: ${'$'}{e.message}, sending unsigned")
|
||||
packet
|
||||
}
|
||||
}
|
||||
@@ -1039,7 +1221,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
peerManager.clearAllFingerprints()
|
||||
Log.d(TAG, "✅ Cleared all mesh service internal data")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Error clearing mesh service internal data: ${e.message}")
|
||||
Log.e(TAG, "❌ Error clearing mesh service internal data: ${'$'}{e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1053,7 +1235,7 @@ class BluetoothMeshService(private val context: Context) {
|
||||
encryptionService.clearPersistentIdentity()
|
||||
Log.d(TAG, "✅ Cleared all encryption data")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Error clearing encryption data: ${e.message}")
|
||||
Log.e(TAG, "❌ Error clearing encryption data: ${'$'}{e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
@@ -17,6 +18,8 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.Job
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.channels.actor
|
||||
|
||||
/**
|
||||
@@ -70,6 +73,7 @@ class BluetoothPacketBroadcaster(
|
||||
try {
|
||||
val fromNick = incomingPeer?.let { nicknameResolver?.invoke(it) }
|
||||
val toNick = toPeer?.let { nicknameResolver?.invoke(it) }
|
||||
val isRelay = (incomingAddr != null || incomingPeer != null)
|
||||
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logPacketRelayDetailed(
|
||||
packetType = typeName,
|
||||
@@ -81,7 +85,8 @@ class BluetoothPacketBroadcaster(
|
||||
toPeerID = toPeer,
|
||||
toNickname = toNick,
|
||||
toDeviceAddress = toDeviceAddress,
|
||||
ttl = ttl
|
||||
ttl = ttl,
|
||||
isRelay = isRelay
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// Silently ignore debug logging failures
|
||||
@@ -97,6 +102,7 @@ class BluetoothPacketBroadcaster(
|
||||
|
||||
// Actor scope for the broadcaster
|
||||
private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val transferJobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
// SERIALIZATION: Actor to serialize all broadcast operations
|
||||
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
|
||||
@@ -119,26 +125,136 @@ class BluetoothPacketBroadcaster(
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
) {
|
||||
val packet = routed.packet
|
||||
val isFile = packet.type == MessageType.FILE_TRANSFER.value
|
||||
if (isFile) {
|
||||
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
|
||||
}
|
||||
// Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
|
||||
val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
|
||||
// Check if we need to fragment
|
||||
if (fragmentManager != null) {
|
||||
val fragments = fragmentManager.createFragments(packet)
|
||||
val fragments = try {
|
||||
fragmentManager.createFragments(packet)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)
|
||||
if (isFile) {
|
||||
Log.e(TAG, "❌ File fragmentation failed for ${packet.payload.size} byte file")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (fragments.size > 1) {
|
||||
if (isFile) {
|
||||
Log.d(TAG, "🔀 File needs ${fragments.size} fragments")
|
||||
}
|
||||
Log.d(TAG, "Fragmenting packet into ${fragments.size} fragments")
|
||||
connectionScope.launch {
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.start(transferId, fragments.size)
|
||||
}
|
||||
val job = connectionScope.launch {
|
||||
var sent = 0
|
||||
fragments.forEach { fragment ->
|
||||
broadcastSinglePacket(RoutedPacket(fragment), gattServer, characteristic)
|
||||
// 20ms delay between fragments (matching iOS/Rust)
|
||||
delay(200)
|
||||
if (!isActive) return@launch
|
||||
// If cancelled, stop sending remaining fragments
|
||||
if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
|
||||
broadcastSinglePacket(RoutedPacket(fragment, transferId = transferId), gattServer, characteristic)
|
||||
// 20ms delay between fragments
|
||||
delay(20)
|
||||
if (transferId != null) {
|
||||
sent += 1
|
||||
TransferProgressManager.progress(transferId, sent, fragments.size)
|
||||
if (sent == fragments.size) TransferProgressManager.complete(transferId, fragments.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (transferId != null) {
|
||||
transferJobs[transferId] = job
|
||||
job.invokeOnCompletion { transferJobs.remove(transferId) }
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Send single packet if no fragmentation needed
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.start(transferId, 1)
|
||||
}
|
||||
broadcastSinglePacket(routed, gattServer, characteristic)
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.progress(transferId, 1, 1)
|
||||
TransferProgressManager.complete(transferId, 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelTransfer(transferId: String): Boolean {
|
||||
val job = transferJobs.remove(transferId) ?: return false
|
||||
job.cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a packet to a specific peer only, without broadcasting.
|
||||
* Returns true if a direct path was found and used.
|
||||
*/
|
||||
fun sendPacketToPeer(
|
||||
routed: RoutedPacket,
|
||||
targetPeerID: String,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
val packet = routed.packet
|
||||
val data = packet.toBinaryData() ?: return false
|
||||
val isFile = packet.type == MessageType.FILE_TRANSFER.value
|
||||
if (isFile) {
|
||||
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
|
||||
}
|
||||
// Prefer caller-provided transferId (e.g., for encrypted media), else derive for FILE_TRANSFER
|
||||
val transferId = routed.transferId ?: (if (isFile) sha256Hex(packet.payload) else null)
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.start(transferId, 1)
|
||||
}
|
||||
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
|
||||
val incomingAddr = routed.relayAddress
|
||||
val incomingPeer = incomingAddr?.let { connectionTracker.addressPeerMap[it] }
|
||||
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
||||
val senderNick = senderPeerID.let { pid -> nicknameResolver?.invoke(pid) }
|
||||
|
||||
// Prefer server-side subscriptions
|
||||
val serverTarget = connectionTracker.getSubscribedDevices()
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
|
||||
if (serverTarget != null) {
|
||||
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, serverTarget.address, packet.ttl)
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.progress(transferId, 1, 1)
|
||||
TransferProgressManager.complete(transferId, 1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Then client connections
|
||||
val clientTarget = connectionTracker.getConnectedDevices().values
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
|
||||
if (clientTarget != null) {
|
||||
if (writeToDeviceConn(clientTarget, data)) {
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, targetPeerID, clientTarget.device.address, packet.ttl)
|
||||
if (transferId != null) {
|
||||
TransferProgressManager.progress(transferId, 1, 1)
|
||||
TransferProgressManager.complete(transferId, 1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun sha256Hex(bytes: ByteArray): String = try {
|
||||
val md = java.security.MessageDigest.getInstance("SHA-256")
|
||||
md.update(bytes)
|
||||
md.digest().joinToString("") { "%02x".format(it) }
|
||||
} catch (_: Exception) { bytes.size.toString(16) }
|
||||
|
||||
|
||||
/**
|
||||
* Public entry point for broadcasting - submits request to actor for serialization
|
||||
|
||||
@@ -48,10 +48,23 @@ class FragmentManager {
|
||||
* Matches iOS sendFragmentedPacket() implementation exactly
|
||||
*/
|
||||
fun createFragments(packet: BitchatPacket): List<BitchatPacket> {
|
||||
val encoded = packet.toBinaryData() ?: return emptyList()
|
||||
try {
|
||||
Log.d(TAG, "🔀 Creating fragments for packet type ${packet.type}, payload: ${packet.payload.size} bytes")
|
||||
val encoded = packet.toBinaryData()
|
||||
if (encoded == null) {
|
||||
Log.e(TAG, "❌ Failed to encode packet to binary data")
|
||||
return emptyList()
|
||||
}
|
||||
Log.d(TAG, "📦 Encoded to ${encoded.size} bytes")
|
||||
|
||||
// Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix
|
||||
val fullData = MessagePadding.unpad(encoded)
|
||||
val fullData = try {
|
||||
MessagePadding.unpad(encoded)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to unpad data: ${e.message}", e)
|
||||
return emptyList()
|
||||
}
|
||||
Log.d(TAG, "📏 Unpadded to ${fullData.size} bytes")
|
||||
|
||||
// iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue
|
||||
if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) {
|
||||
@@ -98,7 +111,13 @@ class FragmentManager {
|
||||
fragments.add(fragmentPacket)
|
||||
}
|
||||
|
||||
return fragments
|
||||
Log.d(TAG, "✅ Created ${fragments.size} fragments successfully")
|
||||
return fragments
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)
|
||||
Log.e(TAG, "❌ Packet type: ${packet.type}, payload: ${packet.payload.size} bytes")
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.bitchat.android.mesh
|
||||
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
@@ -15,7 +16,7 @@ import kotlin.random.Random
|
||||
* Handles processing of different message types
|
||||
* Extracted from BluetoothMeshService for better separation of concerns
|
||||
*/
|
||||
class MessageHandler(private val myPeerID: String) {
|
||||
class MessageHandler(private val myPeerID: String, private val appContext: android.content.Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MessageHandler"
|
||||
@@ -110,6 +111,35 @@ class MessageHandler(private val myPeerID: String) {
|
||||
}
|
||||
}
|
||||
|
||||
com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> {
|
||||
// Handle encrypted file transfer; generate unique message ID
|
||||
val file = com.bitchat.android.model.BitchatFilePacket.decode(noisePayload.data)
|
||||
if (file != null) {
|
||||
Log.d(TAG, "🔓 Decrypted encrypted file from $peerID: name='${file.fileName}', size=${file.fileSize}, mime='${file.mimeType}'")
|
||||
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
|
||||
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
|
||||
val message = BitchatMessage(
|
||||
id = uniqueMsgId,
|
||||
sender = delegate?.getPeerNickname(peerID) ?: "Unknown",
|
||||
content = savedPath,
|
||||
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
|
||||
timestamp = java.util.Date(packet.timestamp.toLong()),
|
||||
isRelay = false,
|
||||
isPrivate = true,
|
||||
recipientNickname = delegate?.getMyNickname(),
|
||||
senderPeerID = peerID
|
||||
)
|
||||
|
||||
Log.d(TAG, "📄 Saved encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
|
||||
delegate?.onMessageReceived(message)
|
||||
|
||||
// Send delivery ACK with generated message ID
|
||||
sendDeliveryAck(uniqueMsgId, peerID)
|
||||
} else {
|
||||
Log.w(TAG, "⚠️ Failed to decode encrypted file transfer from $peerID")
|
||||
}
|
||||
}
|
||||
|
||||
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
|
||||
// Handle delivery ACK exactly like iOS
|
||||
val messageID = String(noisePayload.data, Charsets.UTF_8)
|
||||
@@ -345,16 +375,37 @@ class MessageHandler(private val myPeerID: String) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse message
|
||||
// Try file packet first (voice, image, etc.) and log outcome for FILE_TRANSFER
|
||||
val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == com.bitchat.android.protocol.MessageType.FILE_TRANSFER
|
||||
val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload)
|
||||
if (file != null) {
|
||||
if (isFileTransfer) {
|
||||
Log.d(TAG, "📥 FILE_TRANSFER decode success (broadcast): name='${file.fileName}', size=${file.fileSize}, mime='${file.mimeType}', from=${peerID.take(8)}")
|
||||
}
|
||||
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
|
||||
val message = BitchatMessage(
|
||||
id = java.util.UUID.randomUUID().toString().uppercase(),
|
||||
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
|
||||
content = savedPath,
|
||||
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
|
||||
senderPeerID = peerID,
|
||||
timestamp = Date(packet.timestamp.toLong())
|
||||
)
|
||||
Log.d(TAG, "📄 Saved incoming file to $savedPath")
|
||||
delegate?.onMessageReceived(message)
|
||||
return
|
||||
} else if (isFileTransfer) {
|
||||
Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (broadcast) from ${peerID.take(8)} payloadSize=${packet.payload.size}")
|
||||
}
|
||||
|
||||
// Fallback: plain text
|
||||
val message = BitchatMessage(
|
||||
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
|
||||
content = String(packet.payload, Charsets.UTF_8),
|
||||
senderPeerID = peerID,
|
||||
timestamp = Date(packet.timestamp.toLong())
|
||||
)
|
||||
|
||||
delegate?.onMessageReceived(message)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to process broadcast message: ${e.message}")
|
||||
}
|
||||
@@ -371,7 +422,32 @@ class MessageHandler(private val myPeerID: String) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse message
|
||||
// Try file packet first (voice, image, etc.) and log outcome for FILE_TRANSFER
|
||||
val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == com.bitchat.android.protocol.MessageType.FILE_TRANSFER
|
||||
val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload)
|
||||
if (file != null) {
|
||||
if (isFileTransfer) {
|
||||
Log.d(TAG, "📥 FILE_TRANSFER decode success (private): name='${file.fileName}', size=${file.fileSize}, mime='${file.mimeType}', from=${peerID.take(8)}")
|
||||
}
|
||||
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
|
||||
val message = BitchatMessage(
|
||||
id = java.util.UUID.randomUUID().toString().uppercase(),
|
||||
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
|
||||
content = savedPath,
|
||||
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
|
||||
senderPeerID = peerID,
|
||||
timestamp = Date(packet.timestamp.toLong()),
|
||||
isPrivate = true,
|
||||
recipientNickname = delegate?.getMyNickname()
|
||||
)
|
||||
Log.d(TAG, "📄 Saved incoming file to $savedPath")
|
||||
delegate?.onMessageReceived(message)
|
||||
return
|
||||
} else if (isFileTransfer) {
|
||||
Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (private) from ${peerID.take(8)} payloadSize=${packet.payload.size}")
|
||||
}
|
||||
|
||||
// Fallback: plain text
|
||||
val message = BitchatMessage(
|
||||
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
|
||||
content = String(packet.payload, Charsets.UTF_8),
|
||||
@@ -384,6 +460,8 @@ class MessageHandler(private val myPeerID: String) {
|
||||
Log.e(TAG, "Failed to process private message from $peerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Handle leave message
|
||||
|
||||
@@ -46,15 +46,15 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
private fun getOrCreateActorForPeer(peerID: String) = processorScope.actor<RoutedPacket>(
|
||||
capacity = Channel.UNLIMITED
|
||||
) {
|
||||
Log.d(TAG, "🎭 Created packet actor for peer: ${formatPeerForLog(peerID)}")
|
||||
Log.d(TAG, "🎭 Created packet actor for peer: ${'$'}{formatPeerForLog(peerID)}")
|
||||
try {
|
||||
for (packet in channel) {
|
||||
Log.d(TAG, "📦 Processing packet type ${packet.packet.type} from ${formatPeerForLog(peerID)} (serialized)")
|
||||
Log.d(TAG, "📦 Processing packet type ${'$'}{packet.packet.type} from ${'$'}{formatPeerForLog(peerID)} (serialized)")
|
||||
handleReceivedPacket(packet)
|
||||
Log.d(TAG, "Completed packet type ${packet.packet.type} from ${formatPeerForLog(peerID)}")
|
||||
Log.d(TAG, "Completed packet type ${'$'}{packet.packet.type} from ${'$'}{formatPeerForLog(peerID)}")
|
||||
}
|
||||
} finally {
|
||||
Log.d(TAG, "🎭 Packet actor for ${formatPeerForLog(peerID)} terminated")
|
||||
Log.d(TAG, "🎭 Packet actor for ${'$'}{formatPeerForLog(peerID)} terminated")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
* SURGICAL FIX: Route to per-peer actor for serialized processing
|
||||
*/
|
||||
fun processPacket(routed: RoutedPacket) {
|
||||
Log.d(TAG, "processPacket ${routed.packet.type}")
|
||||
Log.d(TAG, "processPacket ${'$'}{routed.packet.type}")
|
||||
val peerID = routed.peerID
|
||||
|
||||
if (peerID == null) {
|
||||
@@ -89,7 +89,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
try {
|
||||
actor.send(routed)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to send packet to actor for ${formatPeerForLog(peerID)}: ${e.message}")
|
||||
Log.w(TAG, "Failed to send packet to actor for ${'$'}{formatPeerForLog(peerID)}: ${'$'}{e.message}")
|
||||
// Fallback to direct processing if actor fails
|
||||
handleReceivedPacket(routed)
|
||||
}
|
||||
@@ -112,8 +112,8 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
override fun broadcastPacket(routed: RoutedPacket) {
|
||||
delegate?.relayPacket(routed)
|
||||
}
|
||||
override fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
return delegate?.sendToPeer(peerID, routed) ?: false
|
||||
override fun sendPacketToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
return delegate?.sendPacketToPeer(peerID, routed) ?: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,13 +127,13 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
|
||||
// Basic validation and security checks
|
||||
if (!delegate?.validatePacketSecurity(packet, peerID)!!) {
|
||||
Log.d(TAG, "Packet failed security validation from ${formatPeerForLog(peerID)}")
|
||||
Log.d(TAG, "Packet failed security validation from ${'$'}{formatPeerForLog(peerID)}")
|
||||
return
|
||||
}
|
||||
|
||||
var validPacket = true
|
||||
val messageType = MessageType.fromValue(packet.type)
|
||||
Log.d(TAG, "Processing packet type ${messageType} from ${formatPeerForLog(peerID)}")
|
||||
Log.d(TAG, "Processing packet type ${'$'}{messageType} from ${'$'}{formatPeerForLog(peerID)}")
|
||||
// Verbose logging to debug manager (and chat via ChatViewModel observer)
|
||||
try {
|
||||
val mt = messageType?.name ?: packet.type.toString()
|
||||
@@ -147,21 +147,24 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
when (messageType) {
|
||||
MessageType.ANNOUNCE -> handleAnnounce(routed)
|
||||
MessageType.MESSAGE -> handleMessage(routed)
|
||||
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
|
||||
MessageType.LEAVE -> handleLeave(routed)
|
||||
MessageType.FRAGMENT -> handleFragment(routed)
|
||||
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
|
||||
else -> {
|
||||
// Handle private packet types (address check required)
|
||||
if (packetRelayManager.isPacketAddressedToMe(packet)) {
|
||||
when (messageType) {
|
||||
MessageType.NOISE_HANDSHAKE -> handleNoiseHandshake(routed)
|
||||
MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed)
|
||||
MessageType.FILE_TRANSFER -> handleMessage(routed)
|
||||
else -> {
|
||||
validPacket = false
|
||||
Log.w(TAG, "Unknown message type: ${packet.type}")
|
||||
Log.w(TAG, "Unknown message type: ${'$'}{packet.type}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Private packet type ${messageType} not addressed to us (from: ${formatPeerForLog(peerID)} to ${packet.recipientID?.let { it.joinToString("") { b -> "%02x".format(b) } }}), skipping")
|
||||
Log.d(TAG, "Private packet type ${'$'}{messageType} not addressed to us (from: ${'$'}{formatPeerForLog(peerID)} to ${'$'}{packet.recipientID?.let { it.joinToString("") { b -> "%02x".format(b) } }}), skipping")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,7 +183,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
*/
|
||||
private suspend fun handleNoiseHandshake(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing Noise handshake from ${formatPeerForLog(peerID)}")
|
||||
Log.d(TAG, "Processing Noise handshake from ${'$'}{formatPeerForLog(peerID)}")
|
||||
delegate?.handleNoiseHandshake(routed)
|
||||
}
|
||||
|
||||
@@ -189,7 +192,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
*/
|
||||
private suspend fun handleNoiseEncrypted(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing Noise encrypted message from ${formatPeerForLog(peerID)}")
|
||||
Log.d(TAG, "Processing Noise encrypted message from ${'$'}{formatPeerForLog(peerID)}")
|
||||
delegate?.handleNoiseEncrypted(routed)
|
||||
}
|
||||
|
||||
@@ -198,7 +201,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
*/
|
||||
private suspend fun handleAnnounce(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing announce from ${formatPeerForLog(peerID)}")
|
||||
Log.d(TAG, "Processing announce from ${'$'}{formatPeerForLog(peerID)}")
|
||||
delegate?.handleAnnounce(routed)
|
||||
}
|
||||
|
||||
@@ -207,7 +210,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
*/
|
||||
private suspend fun handleMessage(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing message from ${formatPeerForLog(peerID)}")
|
||||
Log.d(TAG, "Processing message from ${'$'}{formatPeerForLog(peerID)}")
|
||||
delegate?.handleMessage(routed)
|
||||
}
|
||||
|
||||
@@ -216,7 +219,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
*/
|
||||
private suspend fun handleLeave(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing leave from ${formatPeerForLog(peerID)}")
|
||||
Log.d(TAG, "Processing leave from ${'$'}{formatPeerForLog(peerID)}")
|
||||
delegate?.handleLeave(routed)
|
||||
}
|
||||
|
||||
@@ -225,7 +228,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
*/
|
||||
private suspend fun handleFragment(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing fragment from ${formatPeerForLog(peerID)}")
|
||||
Log.d(TAG, "Processing fragment from ${'$'}{formatPeerForLog(peerID)}")
|
||||
|
||||
val reassembledPacket = delegate?.handleFragment(routed.packet)
|
||||
if (reassembledPacket != null) {
|
||||
@@ -235,6 +238,15 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
|
||||
// Fragment relay is now handled by centralized PacketRelayManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle REQUEST_SYNC packets (public, TTL=1)
|
||||
*/
|
||||
private suspend fun handleRequestSync(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing REQUEST_SYNC from ${'$'}{formatPeerForLog(peerID)}")
|
||||
delegate?.handleRequestSync(routed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle delivery acknowledgment
|
||||
@@ -251,14 +263,14 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
fun getDebugInfo(): String {
|
||||
return buildString {
|
||||
appendLine("=== Packet Processor Debug Info ===")
|
||||
appendLine("Processor Scope Active: ${processorScope.isActive}")
|
||||
appendLine("Active Peer Actors: ${actors.size}")
|
||||
appendLine("My Peer ID: $myPeerID")
|
||||
appendLine("Processor Scope Active: ${'$'}{processorScope.isActive}")
|
||||
appendLine("Active Peer Actors: ${'$'}{actors.size}")
|
||||
appendLine("My Peer ID: ${'$'}{myPeerID}")
|
||||
|
||||
if (actors.isNotEmpty()) {
|
||||
appendLine("Peer Actors:")
|
||||
actors.keys.forEach { peerID ->
|
||||
appendLine(" - $peerID")
|
||||
appendLine(" - ${'$'}{peerID}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,7 +280,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
* Shutdown the processor and all peer actors
|
||||
*/
|
||||
fun shutdown() {
|
||||
Log.d(TAG, "Shutting down PacketProcessor and ${actors.size} peer actors")
|
||||
Log.d(TAG, "Shutting down PacketProcessor and ${'$'}{actors.size} peer actors")
|
||||
|
||||
// Close all peer actors gracefully
|
||||
actors.values.forEach { actor ->
|
||||
@@ -308,10 +320,11 @@ interface PacketProcessorDelegate {
|
||||
fun handleMessage(routed: RoutedPacket)
|
||||
fun handleLeave(routed: RoutedPacket)
|
||||
fun handleFragment(packet: BitchatPacket): BitchatPacket?
|
||||
fun handleRequestSync(routed: RoutedPacket)
|
||||
|
||||
// Communication
|
||||
fun sendAnnouncementToPeer(peerID: String)
|
||||
fun sendCachedMessages(peerID: String)
|
||||
fun relayPacket(routed: RoutedPacket)
|
||||
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean
|
||||
fun sendPacketToPeer(peerID: String, routed: RoutedPacket): Boolean
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
val packet = routed.packet
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
|
||||
Log.d(TAG, "Evaluating relay for packet type ${'$'}{packet.type} from ${'$'}peerID (TTL: ${'$'}{packet.ttl})")
|
||||
Log.d(TAG, "Evaluating relay for packet type ${'$'}{packet.type} from ${'$'}{peerID} (TTL: ${'$'}{packet.ttl})")
|
||||
|
||||
// Double-check this packet isn't addressed to us
|
||||
if (isPacketAddressedToMe(packet)) {
|
||||
@@ -86,12 +86,12 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
}
|
||||
}
|
||||
if (nextHopIdHex != null) {
|
||||
val success = try { delegate?.sendToPeer(nextHopIdHex, RoutedPacket(relayPacket, peerID, routed.relayAddress)) } catch (_: Exception) { false } ?: false
|
||||
val success = try { delegate?.sendPacketToPeer(nextHopIdHex, RoutedPacket(relayPacket, peerID, routed.relayAddress)) } catch (_: Exception) { false } ?: false
|
||||
if (success) {
|
||||
Log.i(TAG, "📦 Source-route relay: ${myPeerID.take(8)} -> ${nextHopIdHex.take(8)} (type ${'$'}{packet.type}, TTL ${'$'}{relayPacket.ttl})")
|
||||
Log.i(TAG, "📦 Source-route relay: ${'$'}{myPeerID.take(8)} -> ${'$'}{nextHopIdHex.take(8)} (type ${'$'}{packet.type}, TTL ${'$'}{relayPacket.ttl})")
|
||||
return
|
||||
} else {
|
||||
Log.w(TAG, "Source-route next hop ${nextHopIdHex.take(8)} not directly connected; falling back to broadcast")
|
||||
Log.w(TAG, "Source-route next hop ${'$'}{nextHopIdHex.take(8)} not directly connected; falling back to broadcast")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,7 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
|
||||
// Small networks always relay to ensure connectivity
|
||||
if (networkSize <= 3) {
|
||||
Log.d(TAG, "Small network (${ '$' }networkSize peers), relaying")
|
||||
Log.d(TAG, "Small network (${ '$' }{networkSize} peers), relaying")
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -157,47 +157,11 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
}
|
||||
|
||||
val shouldRelay = Random.nextDouble() < relayProb
|
||||
Log.d(TAG, "Network size: ${'$'}networkSize, Relay probability: ${'$'}relayProb, Decision: ${'$'}shouldRelay")
|
||||
Log.d(TAG, "Network size: ${'$'}{networkSize}, Relay probability: ${'$'}{relayProb}, Decision: ${'$'}{shouldRelay}")
|
||||
|
||||
return shouldRelay
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay message with adaptive probability and timing (same as iOS)
|
||||
* Moved from MessageHandler.kt
|
||||
*/
|
||||
suspend fun relayMessage(routed: RoutedPacket) {
|
||||
val packet = routed.packet
|
||||
|
||||
if (packet.ttl == 0u.toUByte()) {
|
||||
Log.d(TAG, "TTL expired, not relaying message")
|
||||
return
|
||||
}
|
||||
|
||||
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
||||
|
||||
// Check network size and apply adaptive relay probability
|
||||
val networkSize = delegate?.getNetworkSize() ?: 1
|
||||
val relayProb = when {
|
||||
networkSize <= 10 -> 1.0
|
||||
networkSize <= 30 -> 0.85
|
||||
networkSize <= 50 -> 0.7
|
||||
networkSize <= 100 -> 0.55
|
||||
else -> 0.4
|
||||
}
|
||||
|
||||
val shouldRelay = relayPacket.ttl >= 4u || networkSize <= 3 || Random.nextDouble() < relayProb
|
||||
|
||||
if (shouldRelay) {
|
||||
val delay = Random.nextLong(50, 500) // Random delay like iOS
|
||||
Log.d(TAG, "Relaying message after ${'$'}delay ms delay")
|
||||
delay(delay)
|
||||
relayPacket(routed.copy(packet = relayPacket))
|
||||
} else {
|
||||
Log.d(TAG, "Relay decision: NOT relaying message (network size: ${'$'}networkSize, prob: ${'$'}relayProb)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actually broadcast the packet for relay
|
||||
*/
|
||||
@@ -213,8 +177,8 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
return buildString {
|
||||
appendLine("=== Packet Relay Manager Debug Info ===")
|
||||
appendLine("Relay Scope Active: ${'$'}{relayScope.isActive}")
|
||||
appendLine("My Peer ID: ${'$'}myPeerID")
|
||||
appendLine("Network Size: ${'$'}{delegate?.getNetworkSize() ?: \"unknown\"}")
|
||||
appendLine("My Peer ID: ${'$'}{myPeerID}")
|
||||
appendLine("Network Size: ${'$'}{delegate?.getNetworkSize() ?: "unknown"}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +201,7 @@ interface PacketRelayManagerDelegate {
|
||||
|
||||
// Packet operations
|
||||
fun broadcastPacket(routed: RoutedPacket)
|
||||
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean
|
||||
fun sendPacketToPeer(peerID: String, routed: RoutedPacket): Boolean
|
||||
}
|
||||
|
||||
private fun hexStringToPeerBytes(hex: String): ByteArray {
|
||||
|
||||
@@ -262,6 +262,8 @@ class PeerManager {
|
||||
fingerprintManager.removePeer(peerID)
|
||||
|
||||
if (notifyDelegate && removed != null) {
|
||||
// Notify specific removal event then list update
|
||||
try { delegate?.onPeerRemoved(peerID) } catch (_: Exception) {}
|
||||
notifyPeerListUpdate()
|
||||
}
|
||||
}
|
||||
@@ -529,4 +531,5 @@ class PeerManager {
|
||||
*/
|
||||
interface PeerManagerDelegate {
|
||||
fun onPeerListUpdated(peerIDs: List<String>)
|
||||
fun onPeerRemoved(peerID: String)
|
||||
}
|
||||
|
||||
@@ -50,12 +50,6 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
return false
|
||||
}
|
||||
|
||||
// TTL check
|
||||
if (packet.ttl == 0u.toUByte()) {
|
||||
Log.d(TAG, "Dropping packet with TTL 0")
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate packet payload
|
||||
if (packet.payload.isEmpty()) {
|
||||
Log.d(TAG, "Dropping packet with empty payload")
|
||||
@@ -67,11 +61,11 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
val packetTime = packet.timestamp.toLong()
|
||||
val timeDiff = kotlin.math.abs(currentTime - packetTime)
|
||||
|
||||
if (timeDiff > MESSAGE_TIMEOUT) {
|
||||
Log.d(TAG, "Dropping old packet from $peerID, time diff: ${timeDiff/1000}s")
|
||||
return false
|
||||
}
|
||||
|
||||
// if (timeDiff > MESSAGE_TIMEOUT) {
|
||||
// Log.d(TAG, "Dropping old packet from $peerID, time diff: ${timeDiff/1000}s")
|
||||
// return false
|
||||
// }
|
||||
|
||||
// Duplicate detection
|
||||
val messageID = generateMessageID(packet, peerID)
|
||||
if (processedMessages.contains(messageID)) {
|
||||
@@ -107,9 +101,17 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
// Skip our own handshake messages
|
||||
if (peerID == myPeerID) return false
|
||||
|
||||
// If we already have an established session but the peer is initiating a new handshake,
|
||||
// drop the existing session so we can re-establish cleanly.
|
||||
var forcedRehandshake = false
|
||||
if (encryptionService.hasEstablishedSession(peerID)) {
|
||||
Log.d(TAG, "Handshake already completed with $peerID")
|
||||
return true
|
||||
Log.d(TAG, "Received new Noise handshake from $peerID with an existing session. Dropping old session to re-handshake.")
|
||||
try {
|
||||
encryptionService.removePeer(peerID)
|
||||
forcedRehandshake = true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to remove existing Noise session for $peerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
if (packet.payload.isEmpty()) {
|
||||
@@ -120,7 +122,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
// Prevent duplicate handshake processing
|
||||
val exchangeKey = "$peerID-${packet.payload.sliceArray(0 until minOf(16, packet.payload.size)).contentHashCode()}"
|
||||
|
||||
if (processedKeyExchanges.contains(exchangeKey)) {
|
||||
if (!forcedRehandshake && processedKeyExchanges.contains(exchangeKey)) {
|
||||
Log.d(TAG, "Already processed handshake: $exchangeKey")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ class StoreForwardManager {
|
||||
|
||||
// Send with delays to avoid overwhelming the connection
|
||||
messagesToSend.forEachIndexed { index, storedMessage ->
|
||||
delay(index * 100L) // 100ms between messages
|
||||
delay(index * 10L) // 10ms between messages
|
||||
delegate?.sendPacket(storedMessage.packet)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class TransferProgressEvent(
|
||||
val transferId: String,
|
||||
val sent: Int,
|
||||
val total: Int,
|
||||
val completed: Boolean
|
||||
)
|
||||
|
||||
object TransferProgressManager {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val _events = MutableSharedFlow<TransferProgressEvent>(replay = 0, extraBufferCapacity = 32)
|
||||
val events: SharedFlow<TransferProgressEvent> = _events
|
||||
|
||||
fun start(id: String, total: Int) { emit(id, 0, total, false) }
|
||||
fun progress(id: String, sent: Int, total: Int) { emit(id, sent, total, sent >= total) }
|
||||
fun complete(id: String, total: Int) { emit(id, total, total, true) }
|
||||
|
||||
private fun emit(id: String, sent: Int, total: Int, done: Boolean) {
|
||||
scope.launch { _events.emit(TransferProgressEvent(id, sent, total, done)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* BitchatFilePacket: TLV-encoded file transfer payload for BLE mesh.
|
||||
* TLVs:
|
||||
* - 0x01: filename (UTF-8)
|
||||
* - 0x02: file size (8 bytes, UInt64)
|
||||
* - 0x03: mime type (UTF-8)
|
||||
* - 0x04: content (bytes) — may appear multiple times for large files
|
||||
*
|
||||
* Length field for TLV is 2 bytes (UInt16, big-endian) for all TLVs.
|
||||
* For large files, CONTENT is chunked into multiple TLVs of up to 65535 bytes each.
|
||||
*
|
||||
* Note: The outer BitchatPacket uses version 2 (4-byte payload length), so this
|
||||
* TLV payload can exceed 64 KiB even though each TLV value is limited to 65535 bytes.
|
||||
* Transport-level fragmentation then splits the final packet for BLE MTU.
|
||||
*/
|
||||
data class BitchatFilePacket(
|
||||
val fileName: String,
|
||||
val fileSize: Long,
|
||||
val mimeType: String,
|
||||
val content: ByteArray
|
||||
) {
|
||||
private enum class TLVType(val v: UByte) {
|
||||
FILE_NAME(0x01u), FILE_SIZE(0x02u), MIME_TYPE(0x03u), CONTENT(0x04u);
|
||||
companion object { fun from(value: UByte) = values().find { it.v == value } }
|
||||
}
|
||||
|
||||
fun encode(): ByteArray? {
|
||||
try {
|
||||
android.util.Log.d("BitchatFilePacket", "🔄 Encoding: name=$fileName, size=$fileSize, mime=$mimeType")
|
||||
val nameBytes = fileName.toByteArray(Charsets.UTF_8)
|
||||
val mimeBytes = mimeType.toByteArray(Charsets.UTF_8)
|
||||
// Validate bounds for 2-byte TLV lengths (per-TLV). CONTENT may exceed 65535 and will be chunked.
|
||||
if (nameBytes.size > 0xFFFF || mimeBytes.size > 0xFFFF) {
|
||||
android.util.Log.e("BitchatFilePacket", "❌ TLV field too large: name=${nameBytes.size}, mime=${mimeBytes.size} (max: 65535)")
|
||||
return null
|
||||
}
|
||||
if (content.size > 0xFFFF) {
|
||||
android.util.Log.d("BitchatFilePacket", "📦 Content exceeds 65535 bytes (${content.size}); will be split into multiple CONTENT TLVs")
|
||||
} else {
|
||||
android.util.Log.d("BitchatFilePacket", "📏 TLV sizes OK: name=${nameBytes.size}, mime=${mimeBytes.size}, content=${content.size}")
|
||||
}
|
||||
val sizeFieldLen = 4 // UInt32 for FILE_SIZE (changed from 8 bytes)
|
||||
val contentLenFieldLen = 4 // UInt32 for CONTENT TLV as requested
|
||||
|
||||
// Compute capacity: header TLVs + single CONTENT TLV with 4-byte length
|
||||
val contentTLVBytes = 1 + contentLenFieldLen + content.size
|
||||
val capacity = (1 + 2 + nameBytes.size) + (1 + 2 + sizeFieldLen) + (1 + 2 + mimeBytes.size) + contentTLVBytes
|
||||
val buf = ByteBuffer.allocate(capacity).order(ByteOrder.BIG_ENDIAN)
|
||||
|
||||
// FILE_NAME
|
||||
buf.put(TLVType.FILE_NAME.v.toByte())
|
||||
buf.putShort(nameBytes.size.toShort())
|
||||
buf.put(nameBytes)
|
||||
|
||||
// FILE_SIZE (4 bytes)
|
||||
buf.put(TLVType.FILE_SIZE.v.toByte())
|
||||
buf.putShort(sizeFieldLen.toShort())
|
||||
buf.putInt(fileSize.toInt())
|
||||
|
||||
// MIME_TYPE
|
||||
buf.put(TLVType.MIME_TYPE.v.toByte())
|
||||
buf.putShort(mimeBytes.size.toShort())
|
||||
buf.put(mimeBytes)
|
||||
|
||||
// CONTENT (single TLV with 4-byte length)
|
||||
buf.put(TLVType.CONTENT.v.toByte())
|
||||
buf.putInt(content.size)
|
||||
buf.put(content)
|
||||
|
||||
val result = buf.array()
|
||||
android.util.Log.d("BitchatFilePacket", "✅ Encoded successfully: ${result.size} bytes total")
|
||||
return result
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("BitchatFilePacket", "❌ Encoding failed: ${e.message}", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun decode(data: ByteArray): BitchatFilePacket? {
|
||||
android.util.Log.d("BitchatFilePacket", "🔄 Decoding ${data.size} bytes")
|
||||
try {
|
||||
var off = 0
|
||||
var name: String? = null
|
||||
var size: Long? = null
|
||||
var mime: String? = null
|
||||
var contentBytes: ByteArray? = null
|
||||
while (off + 3 <= data.size) { // minimum TLV header size (type + 2 bytes length)
|
||||
val t = TLVType.from(data[off].toUByte()) ?: return null
|
||||
off += 1
|
||||
// CONTENT uses 4-byte length; others use 2-byte length
|
||||
val len: Int
|
||||
if (t == TLVType.CONTENT) {
|
||||
if (off + 4 > data.size) return null
|
||||
len = ((data[off].toInt() and 0xFF) shl 24) or ((data[off + 1].toInt() and 0xFF) shl 16) or ((data[off + 2].toInt() and 0xFF) shl 8) or (data[off + 3].toInt() and 0xFF)
|
||||
off += 4
|
||||
} else {
|
||||
if (off + 2 > data.size) return null
|
||||
len = ((data[off].toInt() and 0xFF) shl 8) or (data[off + 1].toInt() and 0xFF)
|
||||
off += 2
|
||||
}
|
||||
if (len < 0 || off + len > data.size) return null
|
||||
val value = data.copyOfRange(off, off + len)
|
||||
off += len
|
||||
when (t) {
|
||||
TLVType.FILE_NAME -> name = String(value, Charsets.UTF_8)
|
||||
TLVType.FILE_SIZE -> {
|
||||
if (len != 4) return null
|
||||
val bb = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN)
|
||||
size = bb.int.toLong()
|
||||
}
|
||||
TLVType.MIME_TYPE -> mime = String(value, Charsets.UTF_8)
|
||||
TLVType.CONTENT -> {
|
||||
// Expect a single CONTENT TLV
|
||||
if (contentBytes == null) contentBytes = value else {
|
||||
// If multiple CONTENT TLVs appear, concatenate for tolerance
|
||||
contentBytes = (contentBytes!! + value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val n = name ?: return null
|
||||
val c = contentBytes ?: return null
|
||||
val s = size ?: c.size.toLong()
|
||||
val m = mime ?: "application/octet-stream"
|
||||
val result = BitchatFilePacket(n, s, m, c)
|
||||
android.util.Log.d("BitchatFilePacket", "✅ Decoded: name=$n, size=$s, mime=$m, content=${c.size} bytes")
|
||||
return result
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("BitchatFilePacket", "❌ Decoding failed: ${e.message}", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,14 @@ import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.*
|
||||
|
||||
@Parcelize
|
||||
enum class BitchatMessageType : Parcelable {
|
||||
Message,
|
||||
Audio,
|
||||
Image,
|
||||
File
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery status for messages - exact same as iOS version
|
||||
*/
|
||||
@@ -49,6 +57,7 @@ data class BitchatMessage(
|
||||
val id: String = UUID.randomUUID().toString().uppercase(),
|
||||
val sender: String,
|
||||
val content: String,
|
||||
val type: BitchatMessageType = BitchatMessageType.Message,
|
||||
val timestamp: Date,
|
||||
val isRelay: Boolean = false,
|
||||
val originalSender: String? = null,
|
||||
@@ -279,6 +288,7 @@ data class BitchatMessage(
|
||||
id = id,
|
||||
sender = sender,
|
||||
content = content,
|
||||
type = BitchatMessageType.Message,
|
||||
timestamp = timestamp,
|
||||
isRelay = isRelay,
|
||||
originalSender = originalSender,
|
||||
@@ -306,6 +316,7 @@ data class BitchatMessage(
|
||||
if (id != other.id) return false
|
||||
if (sender != other.sender) return false
|
||||
if (content != other.content) return false
|
||||
if (type != other.type) return false
|
||||
if (timestamp != other.timestamp) return false
|
||||
if (isRelay != other.isRelay) return false
|
||||
if (originalSender != other.originalSender) return false
|
||||
@@ -328,6 +339,7 @@ data class BitchatMessage(
|
||||
var result = id.hashCode()
|
||||
result = 31 * result + sender.hashCode()
|
||||
result = 31 * result + content.hashCode()
|
||||
result = 31 * result + type.hashCode()
|
||||
result = 31 * result + timestamp.hashCode()
|
||||
result = 31 * result + isRelay.hashCode()
|
||||
result = 31 * result + (originalSender?.hashCode() ?: 0)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.bitchat.android.features.file.FileUtils
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Business logic for file sharing operations
|
||||
*/
|
||||
object FileSharingManager {
|
||||
|
||||
private const val TAG = "FileSharingManager"
|
||||
|
||||
/**
|
||||
* Create a file packet from URI for sending
|
||||
*/
|
||||
fun createFilePacketFromUri(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
originalName: String? = null
|
||||
): BitchatFilePacket? {
|
||||
return try {
|
||||
// Get file name from URI or use original name
|
||||
val fileName = originalName ?: getFileNameFromUri(context, uri) ?: "unknown_file"
|
||||
|
||||
// Copy file to our temp storage for sending
|
||||
val localPath = FileUtils.copyFileForSending(context, uri) ?: return null
|
||||
|
||||
// Determine MIME type
|
||||
val mimeType = FileUtils.getMimeTypeFromExtension(fileName)
|
||||
|
||||
// Read file content
|
||||
val file = File(localPath)
|
||||
val content = file.readBytes()
|
||||
val fileSize = file.length()
|
||||
|
||||
// Clean up temp file
|
||||
file.delete()
|
||||
|
||||
val packet = BitchatFilePacket(
|
||||
fileName = fileName,
|
||||
fileSize = fileSize,
|
||||
mimeType = mimeType,
|
||||
content = content
|
||||
)
|
||||
|
||||
Log.d(TAG, "Created file packet: name=$fileName, size=${FileUtils.formatFileSize(fileSize)}, mime=$mimeType")
|
||||
packet
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create file packet from URI", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from URI
|
||||
*/
|
||||
private fun getFileNameFromUri(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.DISPLAY_NAME)
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(nameIndex)
|
||||
} ?: uri.lastPathSegment
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get filename from URI", e)
|
||||
uri.lastPathSegment
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a received file packet and return file info
|
||||
*/
|
||||
data class ReceivedFileInfo(
|
||||
val fileName: String,
|
||||
val fileSize: Long,
|
||||
val mimeType: String,
|
||||
val content: ByteArray
|
||||
)
|
||||
|
||||
fun processReceivedFile(packet: BitchatFilePacket): ReceivedFileInfo {
|
||||
return ReceivedFileInfo(
|
||||
fileName = packet.fileName,
|
||||
fileSize = packet.fileSize,
|
||||
mimeType = packet.mimeType,
|
||||
content = packet.content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,9 @@ import kotlinx.parcelize.Parcelize
|
||||
enum class NoisePayloadType(val value: UByte) {
|
||||
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
|
||||
READ_RECEIPT(0x02u), // Message was read
|
||||
DELIVERED(0x03u); // Message was delivered
|
||||
DELIVERED(0x03u), // Message was delivered
|
||||
FILE_TRANSFER(0x20u);
|
||||
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: UByte): NoisePayloadType? {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import com.bitchat.android.sync.SyncDefaults
|
||||
|
||||
/**
|
||||
* REQUEST_SYNC payload using GCS (Golomb-Coded Set) parameters.
|
||||
* TLV (type, length16, value), types:
|
||||
* - 0x01: P (uint8) — Golomb-Rice parameter
|
||||
* - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||
* - 0x03: data (opaque) — GR bitstream bytes
|
||||
*/
|
||||
data class RequestSyncPacket(
|
||||
val p: Int,
|
||||
val m: Long,
|
||||
val data: ByteArray
|
||||
) {
|
||||
fun encode(): ByteArray {
|
||||
val out = ArrayList<Byte>()
|
||||
fun putTLV(t: Int, v: ByteArray) {
|
||||
out.add(t.toByte())
|
||||
val len = v.size
|
||||
out.add(((len ushr 8) and 0xFF).toByte())
|
||||
out.add((len and 0xFF).toByte())
|
||||
out.addAll(v.toList())
|
||||
}
|
||||
// P
|
||||
putTLV(0x01, byteArrayOf(p.toByte()))
|
||||
// M (uint32)
|
||||
val m32 = m.coerceAtMost(0xffff_ffffL)
|
||||
putTLV(
|
||||
0x02,
|
||||
byteArrayOf(
|
||||
((m32 ushr 24) and 0xFF).toByte(),
|
||||
((m32 ushr 16) and 0xFF).toByte(),
|
||||
((m32 ushr 8) and 0xFF).toByte(),
|
||||
(m32 and 0xFF).toByte()
|
||||
)
|
||||
)
|
||||
// data
|
||||
putTLV(0x03, data)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Receiver-side safety limit (configurable constant)
|
||||
const val MAX_ACCEPT_FILTER_BYTES: Int = SyncDefaults.MAX_ACCEPT_FILTER_BYTES
|
||||
|
||||
fun decode(data: ByteArray): RequestSyncPacket? {
|
||||
var off = 0
|
||||
var p: Int? = null
|
||||
var m: Long? = null
|
||||
var payload: ByteArray? = null
|
||||
|
||||
while (off + 3 <= data.size) {
|
||||
val t = (data[off].toInt() and 0xFF); off += 1
|
||||
val len = ((data[off].toInt() and 0xFF) shl 8) or (data[off+1].toInt() and 0xFF); off += 2
|
||||
if (off + len > data.size) return null
|
||||
val v = data.copyOfRange(off, off + len); off += len
|
||||
when (t) {
|
||||
0x01 -> if (len == 1) p = (v[0].toInt() and 0xFF)
|
||||
0x02 -> if (len == 4) {
|
||||
val mm = ((v[0].toLong() and 0xFF) shl 24) or
|
||||
((v[1].toLong() and 0xFF) shl 16) or
|
||||
((v[2].toLong() and 0xFF) shl 8) or
|
||||
(v[3].toLong() and 0xFF)
|
||||
m = mm
|
||||
}
|
||||
0x03 -> {
|
||||
if (v.size > MAX_ACCEPT_FILTER_BYTES) return null
|
||||
payload = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val pp = p ?: return null
|
||||
val mm = m ?: return null
|
||||
val dd = payload ?: return null
|
||||
if (pp < 1 || mm <= 0L) return null
|
||||
return RequestSyncPacket(pp, mm, dd)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,6 @@ import com.bitchat.android.protocol.BitchatPacket
|
||||
data class RoutedPacket(
|
||||
val packet: BitchatPacket,
|
||||
val peerID: String? = null, // Who sent it (parsed from packet.senderID)
|
||||
val relayAddress: String? = null // Address it came from (for avoiding loopback)
|
||||
)
|
||||
val relayAddress: String? = null, // Address it came from (for avoiding loopback)
|
||||
val transferId: String? = null // Optional stable transfer ID for progress tracking
|
||||
)
|
||||
|
||||
@@ -43,11 +43,12 @@ object OkHttpProvider {
|
||||
private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder {
|
||||
val builder = OkHttpClient.Builder()
|
||||
val socks: InetSocketAddress? = TorManager.currentSocksAddress()
|
||||
if (socks != null && TorManager.isProxyEnabled()) {
|
||||
// If a SOCKS address is defined, always use it. TorManager sets this as soon as Tor mode is ON,
|
||||
// even before bootstrap, to prevent any direct connections from occurring.
|
||||
if (socks != null) {
|
||||
val proxy = Proxy(Proxy.Type.SOCKS, socks)
|
||||
builder.proxy(proxy)
|
||||
}
|
||||
return builder
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,9 +82,18 @@ object TorManager {
|
||||
currentApplication = application
|
||||
TorPreferenceManager.init(application)
|
||||
|
||||
// Apply saved mode at startup
|
||||
// Apply saved mode at startup. If ON, set planned SOCKS immediately to avoid any leak.
|
||||
val savedMode = TorPreferenceManager.get(application)
|
||||
if (savedMode == TorMode.ON) {
|
||||
if (currentSocksPort < DEFAULT_SOCKS_PORT) {
|
||||
currentSocksPort = DEFAULT_SOCKS_PORT
|
||||
}
|
||||
desiredMode = savedMode
|
||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||
try { OkHttpProvider.reset() } catch (_: Throwable) { }
|
||||
}
|
||||
appScope.launch {
|
||||
applyMode(application, TorPreferenceManager.get(application))
|
||||
applyMode(application, savedMode)
|
||||
}
|
||||
|
||||
// Observe changes
|
||||
@@ -136,6 +145,11 @@ object TorManager {
|
||||
bindRetryAttempts = 0
|
||||
lifecycleState = LifecycleState.STARTING
|
||||
_status.value = _status.value.copy(mode = TorMode.ON, running = false, bootstrapPercent = 0, state = TorState.STARTING)
|
||||
// Immediately set the planned SOCKS address so all traffic is forced through it,
|
||||
// even before Tor is fully bootstrapped. This prevents any direct connections.
|
||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||
try { OkHttpProvider.reset() } catch (_: Throwable) { }
|
||||
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
|
||||
startArti(application, useDelay = false)
|
||||
// Defer enabling proxy until bootstrap completes
|
||||
appScope.launch {
|
||||
@@ -188,6 +202,8 @@ object TorManager {
|
||||
lifecycleState = LifecycleState.RUNNING
|
||||
startInactivityMonitoring()
|
||||
|
||||
// Removed onion service startup (BLE-only file transfer in this branch)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}")
|
||||
_status.value = _status.value.copy(state = TorState.ERROR)
|
||||
@@ -198,6 +214,10 @@ object TorManager {
|
||||
bindRetryAttempts++
|
||||
currentSocksPort++
|
||||
Log.w(TAG, "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort")
|
||||
// Update planned SOCKS address immediately so all new connections target the new port
|
||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||
try { OkHttpProvider.reset() } catch (_: Throwable) { }
|
||||
try { com.bitchat.android.nostr.NostrRelayManager.shared.resetAllConnections() } catch (_: Throwable) { }
|
||||
// Immediate retry with incremented port, no exponential backoff for bind errors
|
||||
startArti(application, useDelay = false)
|
||||
} else if (isBindError) {
|
||||
@@ -339,7 +359,7 @@ object TorManager {
|
||||
completeWaitersIf(TorState.STARTING)
|
||||
}
|
||||
s.contains("Sufficiently bootstrapped; system SOCKS now functional", ignoreCase = true) -> {
|
||||
_status.value = _status.value.copy(bootstrapPercent = 100, state = TorState.BOOTSTRAPPING)
|
||||
_status.value = _status.value.copy(bootstrapPercent = 75, state = TorState.BOOTSTRAPPING)
|
||||
retryAttempts = 0
|
||||
bindRetryAttempts = 0
|
||||
startInactivityMonitoring()
|
||||
|
||||
@@ -8,13 +8,13 @@ object TorPreferenceManager {
|
||||
private const val PREFS_NAME = "bitchat_settings"
|
||||
private const val KEY_TOR_MODE = "tor_mode"
|
||||
|
||||
private val _modeFlow = MutableStateFlow(TorMode.OFF)
|
||||
private val _modeFlow = MutableStateFlow(TorMode.ON)
|
||||
val modeFlow: StateFlow<TorMode> = _modeFlow
|
||||
|
||||
fun init(context: Context) {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val saved = prefs.getString(KEY_TOR_MODE, TorMode.OFF.name)
|
||||
val mode = runCatching { TorMode.valueOf(saved ?: TorMode.OFF.name) }.getOrDefault(TorMode.OFF)
|
||||
val saved = prefs.getString(KEY_TOR_MODE, TorMode.ON.name)
|
||||
val mode = runCatching { TorMode.valueOf(saved ?: TorMode.ON.name) }.getOrDefault(TorMode.ON)
|
||||
_modeFlow.value = mode
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ object TorPreferenceManager {
|
||||
|
||||
fun get(context: Context): TorMode {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val saved = prefs.getString(KEY_TOR_MODE, TorMode.OFF.name)
|
||||
return runCatching { TorMode.valueOf(saved ?: TorMode.OFF.name) }.getOrDefault(TorMode.OFF)
|
||||
val saved = prefs.getString(KEY_TOR_MODE, TorMode.ON.name)
|
||||
return runCatching { TorMode.valueOf(saved ?: TorMode.ON.name) }.getOrDefault(TorMode.ON)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ class GeohashMessageHandler(
|
||||
private val state: ChatState,
|
||||
private val messageManager: MessageManager,
|
||||
private val repo: GeohashRepository,
|
||||
private val scope: CoroutineScope
|
||||
private val scope: CoroutineScope,
|
||||
private val dataManager: com.bitchat.android.ui.DataManager
|
||||
) {
|
||||
companion object { private const val TAG = "GeohashMessageHandler" }
|
||||
|
||||
@@ -56,8 +57,8 @@ class GeohashMessageHandler(
|
||||
if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch
|
||||
}
|
||||
|
||||
// Blocked users check
|
||||
if (com.bitchat.android.ui.DataManager(application).isGeohashUserBlocked(event.pubkey)) return@launch
|
||||
// Blocked users check (use injected DataManager which has loaded state)
|
||||
if (dataManager.isGeohashUserBlocked(event.pubkey)) return@launch
|
||||
|
||||
// Update repository (participants, nickname, teleport)
|
||||
// Update repository on a background-safe path; repository will post updates to LiveData
|
||||
@@ -78,6 +79,7 @@ class GeohashMessageHandler(
|
||||
if (isTeleportPresence) return@launch
|
||||
|
||||
val senderName = repo.displayNameForNostrPubkeyUI(event.pubkey)
|
||||
val hasNonce = try { NostrProofOfWork.hasNonce(event) } catch (_: Exception) { false }
|
||||
val msg = BitchatMessage(
|
||||
id = event.id,
|
||||
sender = senderName,
|
||||
@@ -88,7 +90,9 @@ class GeohashMessageHandler(
|
||||
senderPeerID = "nostr:${event.pubkey.take(8)}",
|
||||
mentions = null,
|
||||
channel = "#$subscribedGeohash",
|
||||
powDifficulty = try { NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } } catch (_: Exception) { null }
|
||||
powDifficulty = try {
|
||||
if (hasNonce) NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } else null
|
||||
} catch (_: Exception) { null }
|
||||
)
|
||||
withContext(Dispatchers.Main) { messageManager.addChannelMessage("geo:$subscribedGeohash", msg) }
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -14,7 +14,8 @@ import java.util.Date
|
||||
*/
|
||||
class GeohashRepository(
|
||||
private val application: Application,
|
||||
private val state: ChatState
|
||||
private val state: ChatState,
|
||||
private val dataManager: com.bitchat.android.ui.DataManager
|
||||
) {
|
||||
companion object { private const val TAG = "GeohashRepository" }
|
||||
|
||||
@@ -103,7 +104,8 @@ class GeohashRepository(
|
||||
val e = it.next()
|
||||
if (e.value.before(cutoff)) it.remove()
|
||||
}
|
||||
return participants.size
|
||||
// exclude blocked users
|
||||
return participants.keys.count { !dataManager.isGeohashUserBlocked(it) }
|
||||
}
|
||||
|
||||
fun refreshGeohashPeople() {
|
||||
@@ -122,8 +124,18 @@ class GeohashRepository(
|
||||
if (e.value.before(cutoff)) it.remove()
|
||||
}
|
||||
geohashParticipants[geohash] = participants
|
||||
val people = participants.map { (pubkeyHex, lastSeen) ->
|
||||
val base = getCachedNickname(pubkeyHex) ?: "anon"
|
||||
// exclude blocked users from people list
|
||||
val people = participants.filterKeys { !dataManager.isGeohashUserBlocked(it) }
|
||||
.map { (pubkeyHex, lastSeen) ->
|
||||
// Use our actual nickname for self; otherwise use cached nickname or anon
|
||||
val base = try {
|
||||
val myHex = currentGeohash?.let { NostrIdentityBridge.deriveIdentity(it, application).publicKeyHex }
|
||||
if (myHex != null && myHex.equals(pubkeyHex, true)) {
|
||||
state.getNicknameValue() ?: "anon"
|
||||
} else {
|
||||
getCachedNickname(pubkeyHex) ?: "anon"
|
||||
}
|
||||
} catch (_: Exception) { getCachedNickname(pubkeyHex) ?: "anon" }
|
||||
GeoPerson(
|
||||
id = pubkeyHex.lowercase(),
|
||||
displayName = base, // UI can add #hash if necessary
|
||||
@@ -138,7 +150,8 @@ class GeohashRepository(
|
||||
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||
val counts = mutableMapOf<String, Int>()
|
||||
for ((gh, participants) in geohashParticipants) {
|
||||
val active = participants.values.count { !it.before(cutoff) }
|
||||
val active = participants.filterKeys { !dataManager.isGeohashUserBlocked(it) }
|
||||
.values.count { !it.before(cutoff) }
|
||||
counts[gh] = active
|
||||
}
|
||||
// Use postValue for thread safety - this can be called from background threads
|
||||
@@ -186,6 +199,7 @@ class GeohashRepository(
|
||||
val participants = geohashParticipants[current] ?: emptyMap()
|
||||
var count = 0
|
||||
for ((k, t) in participants) {
|
||||
if (dataManager.isGeohashUserBlocked(k)) continue
|
||||
if (t.before(cutoff)) continue
|
||||
val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon")
|
||||
if (name.equals(base, true)) { count++; if (count > 1) break }
|
||||
@@ -207,6 +221,7 @@ class GeohashRepository(
|
||||
val participants = geohashParticipants[sourceGeohash] ?: emptyMap()
|
||||
var count = 0
|
||||
for ((k, t) in participants) {
|
||||
if (dataManager.isGeohashUserBlocked(k)) continue
|
||||
if (t.before(cutoff)) continue
|
||||
val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon")
|
||||
if (name.equals(base, true)) { count++; if (count > 1) break }
|
||||
|
||||
@@ -21,7 +21,8 @@ class NostrDirectMessageHandler(
|
||||
private val privateChatManager: PrivateChatManager,
|
||||
private val meshDelegateHandler: MeshDelegateHandler,
|
||||
private val scope: CoroutineScope,
|
||||
private val repo: GeohashRepository
|
||||
private val repo: GeohashRepository,
|
||||
private val dataManager: com.bitchat.android.ui.DataManager
|
||||
) {
|
||||
companion object { private const val TAG = "NostrDirectMessageHandler" }
|
||||
|
||||
@@ -58,6 +59,10 @@ class NostrDirectMessageHandler(
|
||||
}
|
||||
|
||||
val (content, senderPubkey, rumorTimestamp) = decryptResult
|
||||
|
||||
// If sender is blocked for geohash contexts, drop any events from this pubkey
|
||||
// Applies to both geohash DMs (geohash != "") and account DMs (geohash == "")
|
||||
if (dataManager.isGeohashUserBlocked(senderPubkey)) return@launch
|
||||
if (!content.startsWith("bitchat1:")) return@launch
|
||||
|
||||
val base64Content = content.removePrefix("bitchat1:")
|
||||
@@ -67,7 +72,7 @@ class NostrDirectMessageHandler(
|
||||
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
|
||||
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch
|
||||
val messageTimestamp = Date(rumorTimestamp * 1000L)
|
||||
val messageTimestamp = Date(giftWrap.createdAt * 1000L)
|
||||
val convKey = "nostr_${senderPubkey.take(16)}"
|
||||
repo.putNostrKeyMapping(convKey, senderPubkey)
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.put(convKey, senderPubkey)
|
||||
@@ -155,6 +160,31 @@ class NostrDirectMessageHandler(
|
||||
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
|
||||
}
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER -> {
|
||||
// Properly handle encrypted file transfer
|
||||
val file = com.bitchat.android.model.BitchatFilePacket.decode(payload.data)
|
||||
if (file != null) {
|
||||
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
|
||||
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(application, file)
|
||||
val message = BitchatMessage(
|
||||
id = uniqueMsgId,
|
||||
sender = senderNickname,
|
||||
content = savedPath,
|
||||
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
|
||||
timestamp = timestamp,
|
||||
isRelay = false,
|
||||
isPrivate = true,
|
||||
recipientNickname = state.getNicknameValue(),
|
||||
senderPeerID = convKey
|
||||
)
|
||||
Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
|
||||
withContext(Dispatchers.Main) {
|
||||
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = false)
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "⚠️ Failed to decode Nostr file transfer from $convKey")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,4 +203,3 @@ class NostrDirectMessageHandler(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,12 @@ object NostrProofOfWork {
|
||||
fun validateDifficulty(event: NostrEvent, minimumDifficulty: Int): Boolean {
|
||||
if (minimumDifficulty <= 0) return true
|
||||
|
||||
// Require explicit nonce tag to recognize PoW per NIP-13 intent
|
||||
if (!hasNonce(event)) {
|
||||
Log.w(TAG, "Event ${event.id.take(16)}... missing nonce tag; treating as no PoW")
|
||||
return false
|
||||
}
|
||||
|
||||
val actualDifficulty = calculateDifficulty(event.id)
|
||||
val committedDifficulty = getCommittedDifficulty(event)
|
||||
|
||||
|
||||
@@ -240,11 +240,7 @@ class NostrTransport(
|
||||
return@launch
|
||||
}
|
||||
|
||||
val content = if (isFavorite) {
|
||||
"[FAVORITED]:${senderIdentity.npub}"
|
||||
} else {
|
||||
"[UNFAVORITED]:${senderIdentity.npub}"
|
||||
}
|
||||
val content = if (isFavorite) "[FAVORITED]:${senderIdentity.npub}" else "[UNFAVORITED]:${senderIdentity.npub}"
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing FAVORITE($isFavorite) to ${recipientNostrPubkey.take(16)}...")
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ object PoWPreferenceManager {
|
||||
|
||||
// Default values
|
||||
private const val DEFAULT_POW_ENABLED = false
|
||||
private const val DEFAULT_POW_DIFFICULTY = 16 // Reasonable default for geohash spam prevention
|
||||
private const val DEFAULT_POW_DIFFICULTY = 12 // Reasonable default for geohash spam prevention
|
||||
|
||||
// State flows for reactive UI
|
||||
private val _powEnabled = MutableStateFlow(DEFAULT_POW_ENABLED)
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Preference manager for battery optimization skip choice
|
||||
*/
|
||||
object BatteryOptimizationPreferenceManager {
|
||||
private const val PREFS_NAME = "bitchat_settings"
|
||||
private const val KEY_BATTERY_SKIP = "battery_optimization_skipped"
|
||||
|
||||
private val _skipFlow = MutableStateFlow(false)
|
||||
val skipFlow: StateFlow<Boolean> = _skipFlow
|
||||
|
||||
fun init(context: Context) {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val skipped = prefs.getBoolean(KEY_BATTERY_SKIP, false)
|
||||
_skipFlow.value = skipped
|
||||
}
|
||||
|
||||
fun setSkipped(context: Context, skipped: Boolean) {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit().putBoolean(KEY_BATTERY_SKIP, skipped).apply()
|
||||
_skipFlow.value = skipped
|
||||
}
|
||||
|
||||
fun isSkipped(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(KEY_BATTERY_SKIP, false)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.bitchat.android.onboarding
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
@@ -12,11 +13,13 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
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.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
@@ -32,10 +35,16 @@ fun BatteryOptimizationScreen(
|
||||
onSkip: () -> Unit,
|
||||
isLoading: Boolean = false
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
// Initialize preference manager
|
||||
LaunchedEffect(Unit) {
|
||||
BatteryOptimizationPreferenceManager.init(context)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.padding(32.dp),
|
||||
modifier = modifier.padding(24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (status) {
|
||||
@@ -73,6 +82,8 @@ private fun BatteryOptimizationEnabledContent(
|
||||
colorScheme: ColorScheme,
|
||||
isLoading: Boolean
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
@@ -82,78 +93,112 @@ private fun BatteryOptimizationEnabledContent(
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "bitchat",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
// Header Section - matching AboutSheet style
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "bitchat",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "battery optimization detected",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.BatteryAlert,
|
||||
contentDescription = "Battery Optimization",
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = colorScheme.error
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_detected),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = colorScheme.onSurface
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_explanation),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = colorScheme.onSurfaceVariant
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Card(
|
||||
// Battery optimization info section
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_why_disable),
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = colorScheme.onSurface
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Power,
|
||||
contentDescription = "Battery Optimization",
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_benefits),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = "Battery Optimization Enabled",
|
||||
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.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_note),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
color = colorScheme.onSurfaceVariant
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
// Benefits section
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = "Benefits",
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = "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",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed buttons at the bottom
|
||||
@@ -164,7 +209,10 @@ private fun BatteryOptimizationEnabledContent(
|
||||
Button(
|
||||
onClick = onDisableBatteryOptimization,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLoading
|
||||
enabled = !isLoading,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = colorScheme.primary
|
||||
)
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
@@ -174,7 +222,13 @@ private fun BatteryOptimizationEnabledContent(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(stringResource(R.string.battery_optimization_disable_button))
|
||||
Text(
|
||||
text = "Disable Battery Optimization",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -186,15 +240,28 @@ private fun BatteryOptimizationEnabledContent(
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !isLoading
|
||||
) {
|
||||
Text(stringResource(R.string.battery_optimization_check_again))
|
||||
Text(
|
||||
text = "Check Again",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onSkip,
|
||||
onClick = {
|
||||
BatteryOptimizationPreferenceManager.setSkipped(context, true)
|
||||
onSkip()
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !isLoading
|
||||
) {
|
||||
Text(stringResource(R.string.battery_optimization_skip))
|
||||
Text(
|
||||
text = "Skip for Now",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,17 +273,31 @@ private fun BatteryOptimizationCheckingContent(
|
||||
colorScheme: ColorScheme
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "bitchat",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary
|
||||
// Header Section - matching AboutSheet style
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "bitchat",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "battery optimization disabled",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "rotation")
|
||||
val rotation by infiniteTransition.animateFloat(
|
||||
@@ -239,18 +320,10 @@ private fun BatteryOptimizationCheckingContent(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_disabled),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = colorScheme.onSurface
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_success_message),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = colorScheme.onSurfaceVariant
|
||||
text = "bitchat can run reliably in the background",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
@@ -266,14 +339,28 @@ private fun BatteryOptimizationNotSupportedContent(
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "bitchat",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary
|
||||
// Header Section - matching AboutSheet style
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "bitchat",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "battery optimization not required",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
@@ -283,27 +370,28 @@ private fun BatteryOptimizationNotSupportedContent(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_not_required),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = colorScheme.onSurface
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_not_supported_message),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = colorScheme.onSurfaceVariant
|
||||
text = "your device doesn't require battery optimization settings. bitchat will run normally.",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text(stringResource(R.string.battery_optimization_continue))
|
||||
Text(
|
||||
text = "Continue",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,23 @@ package com.bitchat.android.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Power
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -40,86 +51,87 @@ fun PermissionExplanationScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
// Header
|
||||
|
||||
// Header Section - matching AboutSheet style
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "bitchat",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Welcome to bitchat",
|
||||
style = MaterialTheme.typography.headlineMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Decentralized mesh messaging over Bluetooth",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
text = "decentralized mesh messaging with end-to-end encryption",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Privacy assurance section
|
||||
Card(
|
||||
// Privacy assurance section - matching AboutSheet card style
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "🔒",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.size(20.dp)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Security,
|
||||
contentDescription = "Privacy Protected",
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Your Privacy is Protected",
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.onSurface
|
||||
Column {
|
||||
Text(
|
||||
text = "Your Privacy is 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",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "• bitchat doesn't track you or collect personal data\n" +
|
||||
"• Bluetooth mesh chats are fully offline and require no internet\n" +
|
||||
"• Geohash chats use the internet but your location is generalized\n" +
|
||||
"• Your messages stay on your device and peer devices only",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Section header
|
||||
Text(
|
||||
text = "To work properly, bitchat needs these permissions:",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
text = "permissions",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 8.dp)
|
||||
)
|
||||
|
||||
// Permission categories
|
||||
@@ -168,55 +180,46 @@ private fun PermissionCategoryCard(
|
||||
category: PermissionCategory,
|
||||
colorScheme: ColorScheme
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = getPermissionEmoji(category.type),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = getPermissionIconColor(category.type),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = category.type.nameValue,
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = getPermissionIcon(category.type),
|
||||
contentDescription = category.type.nameValue,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = category.type.nameValue,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = category.description,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f),
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
|
||||
if (category.type == PermissionType.PRECISE_LOCATION) {
|
||||
// Extra emphasis for location permission
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "⚠️",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = "Warning",
|
||||
tint = Color(0xFFFF9800),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
@@ -233,22 +236,13 @@ private fun PermissionCategoryCard(
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPermissionEmoji(permissionType: PermissionType): String {
|
||||
private fun getPermissionIcon(permissionType: PermissionType): ImageVector {
|
||||
return when (permissionType) {
|
||||
PermissionType.NEARBY_DEVICES -> "📱"
|
||||
PermissionType.PRECISE_LOCATION -> "📍"
|
||||
PermissionType.NOTIFICATIONS -> "🔔"
|
||||
PermissionType.BATTERY_OPTIMIZATION -> "🔋"
|
||||
PermissionType.OTHER -> "🔧"
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPermissionIconColor(permissionType: PermissionType): Color {
|
||||
return when (permissionType) {
|
||||
PermissionType.NEARBY_DEVICES -> Color(0xFF2196F3) // Blue
|
||||
PermissionType.PRECISE_LOCATION -> Color(0xFFFF9800) // Orange
|
||||
PermissionType.NOTIFICATIONS -> Color(0xFF4CAF50) // Green
|
||||
PermissionType.BATTERY_OPTIMIZATION -> Color(0xFFF44336) // Red
|
||||
PermissionType.OTHER -> Color(0xFF9C27B0) // Purple
|
||||
PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth
|
||||
PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn
|
||||
PermissionType.MICROPHONE -> Icons.Filled.Mic
|
||||
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
|
||||
PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power
|
||||
PermissionType.OTHER -> Icons.Filled.Settings
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ class PermissionManager(private val context: Context) {
|
||||
*/
|
||||
fun getOptionalPermissions(): List<String> {
|
||||
val optional = mutableListOf<String>()
|
||||
// Notifications on Android 13+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
optional.add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
@@ -189,6 +190,8 @@ class PermissionManager(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
// Microphone category removed from onboarding
|
||||
|
||||
// Battery optimization category (if applicable)
|
||||
if (isBatteryOptimizationSupported()) {
|
||||
categories.add(
|
||||
@@ -257,6 +260,7 @@ data class PermissionCategory(
|
||||
enum class PermissionType(val nameValue: String) {
|
||||
NEARBY_DEVICES("Nearby Devices"),
|
||||
PRECISE_LOCATION("Precise Location"),
|
||||
MICROPHONE("Microphone"),
|
||||
NOTIFICATIONS("Notifications"),
|
||||
BATTERY_OPTIMIZATION("Battery Optimization"),
|
||||
OTHER("Other")
|
||||
|
||||
@@ -15,7 +15,9 @@ enum class MessageType(val value: UByte) {
|
||||
LEAVE(0x03u),
|
||||
NOISE_HANDSHAKE(0x10u), // Noise handshake
|
||||
NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message
|
||||
FRAGMENT(0x20u); // Fragmentation for large packets
|
||||
FRAGMENT(0x20u), // Fragmentation for large packets
|
||||
REQUEST_SYNC(0x21u), // GCS-based sync request
|
||||
FILE_TRANSFER(0x22u); // New: File transfer packet (BLE voice notes, etc.)
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: UByte): MessageType? {
|
||||
@@ -32,15 +34,15 @@ object SpecialRecipients {
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary packet format - 100% compatible with iOS version
|
||||
*
|
||||
* Header (Fixed 13 bytes):
|
||||
* Binary packet format - 100% backward compatible with iOS version
|
||||
*
|
||||
* Header (13 bytes for v1, 15 bytes for v2):
|
||||
* - Version: 1 byte
|
||||
* - Type: 1 byte
|
||||
* - Type: 1 byte
|
||||
* - TTL: 1 byte
|
||||
* - Timestamp: 8 bytes (UInt64, big-endian)
|
||||
* - Flags: 1 byte (bit 0: hasRecipient, bit 1: hasSignature, bit 2: isCompressed)
|
||||
* - PayloadLength: 2 bytes (UInt16, big-endian)
|
||||
* - PayloadLength: 2 bytes (v1) / 4 bytes (v2) (big-endian)
|
||||
*
|
||||
* Variable sections:
|
||||
* - SenderID: 8 bytes (fixed)
|
||||
@@ -173,20 +175,28 @@ data class BitchatPacket(
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary Protocol implementation - exact same format as iOS version
|
||||
* Binary Protocol implementation - supports v1 and v2, backward compatible
|
||||
*/
|
||||
object BinaryProtocol {
|
||||
private const val HEADER_SIZE = 13
|
||||
private const val HEADER_SIZE_V1 = 13
|
||||
private const val HEADER_SIZE_V2 = 15
|
||||
private const val SENDER_ID_SIZE = 8
|
||||
private const val RECIPIENT_ID_SIZE = 8
|
||||
private const val SIGNATURE_SIZE = 64
|
||||
|
||||
|
||||
object Flags {
|
||||
const val HAS_RECIPIENT: UByte = 0x01u
|
||||
const val HAS_SIGNATURE: UByte = 0x02u
|
||||
const val IS_COMPRESSED: UByte = 0x04u
|
||||
const val HAS_ROUTE: UByte = 0x08u
|
||||
}
|
||||
|
||||
private fun getHeaderSize(version: UByte): Int {
|
||||
return when (version) {
|
||||
1u.toUByte() -> HEADER_SIZE_V1
|
||||
else -> HEADER_SIZE_V2 // v2+ will use 4-byte payload length
|
||||
}
|
||||
}
|
||||
|
||||
fun encode(packet: BitchatPacket): ByteArray? {
|
||||
try {
|
||||
@@ -203,7 +213,13 @@ object BinaryProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
val buffer = ByteBuffer.allocate(4096).apply { order(ByteOrder.BIG_ENDIAN) }
|
||||
// Compute a safe capacity for the unpadded frame
|
||||
val headerSize = getHeaderSize(packet.version)
|
||||
val recipientBytes = if (packet.recipientID != null) RECIPIENT_ID_SIZE else 0
|
||||
val signatureBytes = if (packet.signature != null) SIGNATURE_SIZE else 0
|
||||
val payloadBytes = payload.size + if (isCompressed) 2 else 0
|
||||
val capacity = headerSize + SENDER_ID_SIZE + recipientBytes + payloadBytes + signatureBytes + 16 // small slack
|
||||
val buffer = ByteBuffer.allocate(capacity.coerceAtLeast(512)).apply { order(ByteOrder.BIG_ENDIAN) }
|
||||
|
||||
// Header
|
||||
buffer.put(packet.version.toByte())
|
||||
@@ -229,9 +245,13 @@ object BinaryProtocol {
|
||||
}
|
||||
buffer.put(flags.toByte())
|
||||
|
||||
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
||||
// Payload length (2 or 4 bytes, big-endian) - includes original size if compressed
|
||||
val payloadDataSize = payload.size + if (isCompressed) 2 else 0
|
||||
buffer.putShort(payloadDataSize.toShort())
|
||||
if (packet.version >= 2u.toUByte()) {
|
||||
buffer.putInt(payloadDataSize) // 4 bytes for v2+
|
||||
} else {
|
||||
buffer.putShort(payloadDataSize.toShort()) // 2 bytes for v1
|
||||
}
|
||||
|
||||
// SenderID (exactly 8 bytes)
|
||||
val senderBytes = packet.senderID.take(SENDER_ID_SIZE).toByteArray()
|
||||
@@ -282,7 +302,7 @@ object BinaryProtocol {
|
||||
return paddedData
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("BinaryProtocol", "Error encoding packet type ${packet.type}: ${e.message}")
|
||||
Log.e("BinaryProtocol", "Error encoding packet type ${'$'}{packet.type}: ${'$'}{e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -303,20 +323,22 @@ object BinaryProtocol {
|
||||
*/
|
||||
private fun decodeCore(raw: ByteArray): BitchatPacket? {
|
||||
try {
|
||||
if (raw.size < HEADER_SIZE + SENDER_ID_SIZE) return null
|
||||
|
||||
if (raw.size < HEADER_SIZE_V1 + SENDER_ID_SIZE) return null
|
||||
|
||||
val buffer = ByteBuffer.wrap(raw).apply { order(ByteOrder.BIG_ENDIAN) }
|
||||
|
||||
|
||||
// Header
|
||||
val version = buffer.get().toUByte()
|
||||
if (version != 1u.toUByte()) return null
|
||||
|
||||
if (version.toUInt() != 1u && version.toUInt() != 2u) return null // Support v1 and v2
|
||||
|
||||
val headerSize = getHeaderSize(version)
|
||||
|
||||
val type = buffer.get().toUByte()
|
||||
val ttl = buffer.get().toUByte()
|
||||
|
||||
|
||||
// Timestamp
|
||||
val timestamp = buffer.getLong().toULong()
|
||||
|
||||
|
||||
// Flags
|
||||
val flags = buffer.get().toUByte()
|
||||
val hasRecipient = (flags and Flags.HAS_RECIPIENT) != 0u.toUByte()
|
||||
@@ -324,11 +346,15 @@ object BinaryProtocol {
|
||||
val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte()
|
||||
val hasRoute = (flags and Flags.HAS_ROUTE) != 0u.toUByte()
|
||||
|
||||
// Payload length
|
||||
val payloadLength = buffer.getShort().toUShort()
|
||||
|
||||
// Payload length - version-dependent (2 or 4 bytes)
|
||||
val payloadLength = if (version >= 2u.toUByte()) {
|
||||
buffer.getInt().toUInt() // 4 bytes for v2+
|
||||
} else {
|
||||
buffer.getShort().toUShort().toUInt() // 2 bytes for v1, convert to UInt
|
||||
}
|
||||
|
||||
// Calculate expected total size
|
||||
var expectedSize = HEADER_SIZE + SENDER_ID_SIZE + payloadLength.toInt()
|
||||
var expectedSize = headerSize + SENDER_ID_SIZE + payloadLength.toInt()
|
||||
if (hasRecipient) expectedSize += RECIPIENT_ID_SIZE
|
||||
var routeCount = 0
|
||||
if (hasRoute) {
|
||||
@@ -340,7 +366,7 @@ object BinaryProtocol {
|
||||
expectedSize += 1 + (routeCount * SENDER_ID_SIZE)
|
||||
}
|
||||
if (hasSignature) expectedSize += SIGNATURE_SIZE
|
||||
|
||||
|
||||
if (raw.size < expectedSize) return null
|
||||
|
||||
// SenderID
|
||||
@@ -404,7 +430,7 @@ object BinaryProtocol {
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("BinaryProtocol", "Error decoding packet: ${e.message}")
|
||||
Log.e("BinaryProtocol", "Error decoding packet: ${'$'}{e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,9 +114,7 @@ class MessageRouter private constructor(
|
||||
|
||||
fun sendFavoriteNotification(toPeerID: String, isFavorite: Boolean) {
|
||||
if (mesh.getPeerInfo(toPeerID)?.isConnected == true) {
|
||||
val myNpub = try {
|
||||
com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub
|
||||
} catch (_: Exception) { null }
|
||||
val myNpub = try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub } catch (_: Exception) { null }
|
||||
val content = if (isFavorite) "[FAVORITED]:${myNpub ?: ""}" else "[UNFAVORITED]:${myNpub ?: ""}"
|
||||
val nickname = mesh.getPeerNicknames()[toPeerID] ?: toPeerID
|
||||
mesh.sendPrivateMessage(content, toPeerID, nickname)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.bitchat.android.sync
|
||||
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.ln
|
||||
|
||||
/**
|
||||
* Golomb-Coded Set (GCS) filter implementation for sync.
|
||||
*
|
||||
* Hashing:
|
||||
* - h64(id) = first 8 bytes of SHA-256 over the 16-byte PacketId (big-endian unsigned)
|
||||
* - Map to range [0, M) via (h64 % M)
|
||||
*
|
||||
* Encoding (v1):
|
||||
* - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers
|
||||
* - For each delta x >= 1, write Golomb-Rice code with parameter P:
|
||||
* q = (x - 1) >> P (unary q ones followed by a zero), then P low bits r = (x - 1) & ((1<<P)-1)
|
||||
* - Bitstream is packed MSB-first in each byte.
|
||||
*/
|
||||
object GCSFilter {
|
||||
data class Params(
|
||||
val p: Int, // Golomb-Rice parameter (>= 1)
|
||||
val m: Long, // Range M = N * 2^P
|
||||
val data: ByteArray // Encoded GR bitstream
|
||||
)
|
||||
|
||||
// Derive P from target FPR; FPR ~= 1 / 2^P
|
||||
fun deriveP(targetFpr: Double): Int {
|
||||
val f = targetFpr.coerceIn(0.000001, 0.25)
|
||||
return ceil(ln(1.0 / f) / ln(2.0)).toInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
// Rough capacity estimate: expected bits per element ~= P + 2 (quotient unary ~ around 2 bits)
|
||||
fun estimateMaxElementsForSize(bytes: Int, p: Int): Int {
|
||||
val bits = (bytes * 8).coerceAtLeast(8)
|
||||
val per = (p + 2).coerceAtLeast(3)
|
||||
return (bits / per).coerceAtLeast(1)
|
||||
}
|
||||
|
||||
fun buildFilter(
|
||||
ids: List<ByteArray>, // 16-byte PacketId bytes
|
||||
maxBytes: Int,
|
||||
targetFpr: Double
|
||||
): Params {
|
||||
val p = deriveP(targetFpr)
|
||||
var nCap = estimateMaxElementsForSize(maxBytes, p)
|
||||
val n = ids.size.coerceAtMost(nCap)
|
||||
val selected = ids.take(n)
|
||||
// Map to [0, M)
|
||||
val m = (n.toLong() shl p)
|
||||
val mapped = selected.map { id -> (h64(id) % m) }.sorted()
|
||||
var encoded = encode(mapped, p)
|
||||
// If estimate was too optimistic, trim until it fits
|
||||
var trimmedN = n
|
||||
while (encoded.size > maxBytes && trimmedN > 0) {
|
||||
trimmedN = (trimmedN * 9) / 10 // drop 10%
|
||||
val mapped2 = mapped.take(trimmedN)
|
||||
encoded = encode(mapped2, p)
|
||||
}
|
||||
val finalM = (trimmedN.toLong() shl p)
|
||||
return Params(p = p, m = finalM, data = encoded)
|
||||
}
|
||||
|
||||
fun decodeToSortedSet(p: Int, m: Long, data: ByteArray): LongArray {
|
||||
val values = ArrayList<Long>()
|
||||
val reader = BitReader(data)
|
||||
var acc = 0L
|
||||
val mask = (1L shl p) - 1L
|
||||
while (!reader.eof()) {
|
||||
// Read unary quotient (q ones terminated by zero)
|
||||
var q = 0L
|
||||
while (true) {
|
||||
val b = reader.readBit() ?: break
|
||||
if (b == 1) q++ else break
|
||||
}
|
||||
if (reader.lastWasEOF) break
|
||||
// Read remainder
|
||||
val r = reader.readBits(p) ?: break
|
||||
val x = (q shl p) + r + 1
|
||||
acc += x
|
||||
if (acc >= m) break // out of range safeguard
|
||||
values.add(acc)
|
||||
}
|
||||
return values.toLongArray()
|
||||
}
|
||||
|
||||
fun contains(sortedValues: LongArray, candidate: Long): Boolean {
|
||||
var lo = 0
|
||||
var hi = sortedValues.size - 1
|
||||
while (lo <= hi) {
|
||||
val mid = (lo + hi) ushr 1
|
||||
val v = sortedValues[mid]
|
||||
if (v == candidate) return true
|
||||
if (v < candidate) lo = mid + 1 else hi = mid - 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun h64(id16: ByteArray): Long {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
md.update(id16)
|
||||
val d = md.digest()
|
||||
var x = 0L
|
||||
for (i in 0 until 8) {
|
||||
x = (x shl 8) or ((d[i].toLong() and 0xFF))
|
||||
}
|
||||
return x and 0x7fff_ffff_ffff_ffffL // positive
|
||||
}
|
||||
|
||||
private fun encode(sorted: List<Long>, p: Int): ByteArray {
|
||||
val bw = BitWriter()
|
||||
var prev = 0L
|
||||
val mask = (1L shl p) - 1L
|
||||
for (v in sorted) {
|
||||
val delta = v - prev
|
||||
prev = v
|
||||
val x = delta
|
||||
val q = (x - 1) ushr p
|
||||
val r = (x - 1) and mask
|
||||
// unary q ones then a zero
|
||||
repeat(q.toInt()) { bw.writeBit(1) }
|
||||
bw.writeBit(0)
|
||||
// then P bits of r (MSB-first)
|
||||
bw.writeBits(r, p)
|
||||
}
|
||||
return bw.toByteArray()
|
||||
}
|
||||
|
||||
// Simple MSB-first bit writer
|
||||
private class BitWriter {
|
||||
private val buf = ArrayList<Byte>()
|
||||
private var cur = 0
|
||||
private var nbits = 0
|
||||
fun writeBit(bit: Int) {
|
||||
cur = (cur shl 1) or (bit and 1)
|
||||
nbits++
|
||||
if (nbits == 8) {
|
||||
buf.add(cur.toByte())
|
||||
cur = 0; nbits = 0
|
||||
}
|
||||
}
|
||||
fun writeBits(value: Long, count: Int) {
|
||||
if (count <= 0) return
|
||||
for (i in count - 1 downTo 0) {
|
||||
val bit = ((value ushr i) and 1L).toInt()
|
||||
writeBit(bit)
|
||||
}
|
||||
}
|
||||
fun toByteArray(): ByteArray {
|
||||
if (nbits > 0) {
|
||||
val rem = cur shl (8 - nbits)
|
||||
buf.add(rem.toByte())
|
||||
cur = 0; nbits = 0
|
||||
}
|
||||
return buf.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
// Simple MSB-first bit reader
|
||||
private class BitReader(private val data: ByteArray) {
|
||||
private var i = 0
|
||||
private var nleft = 8
|
||||
private var cur = if (data.isNotEmpty()) (data[0].toInt() and 0xFF) else 0
|
||||
var lastWasEOF: Boolean = false
|
||||
private set
|
||||
fun eof() = i >= data.size
|
||||
fun readBit(): Int? {
|
||||
if (i >= data.size) { lastWasEOF = true; return null }
|
||||
val bit = (cur ushr 7) and 1
|
||||
cur = (cur shl 1) and 0xFF
|
||||
nleft--
|
||||
if (nleft == 0) {
|
||||
i++
|
||||
if (i < data.size) {
|
||||
cur = data[i].toInt() and 0xFF
|
||||
nleft = 8
|
||||
}
|
||||
}
|
||||
return bit
|
||||
}
|
||||
fun readBits(count: Int): Long? {
|
||||
var v = 0L
|
||||
for (k in 0 until count) {
|
||||
val b = readBit() ?: return null
|
||||
v = (v shl 1) or b.toLong()
|
||||
}
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package com.bitchat.android.sync
|
||||
|
||||
import android.util.Log
|
||||
import com.bitchat.android.mesh.BluetoothPacketBroadcaster
|
||||
import com.bitchat.android.model.RequestSyncPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Gossip-based synchronization manager using on-demand GCS filters.
|
||||
* Tracks seen public packets (ANNOUNCE, broadcast MESSAGE) and periodically requests sync
|
||||
* from neighbors. Responds to REQUEST_SYNC by sending missing packets.
|
||||
*/
|
||||
class GossipSyncManager(
|
||||
private val myPeerID: String,
|
||||
private val scope: CoroutineScope,
|
||||
private val configProvider: ConfigProvider
|
||||
) {
|
||||
interface Delegate {
|
||||
fun sendPacket(packet: BitchatPacket)
|
||||
fun sendPacketToPeer(peerID: String, packet: BitchatPacket)
|
||||
fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket
|
||||
}
|
||||
|
||||
interface ConfigProvider {
|
||||
fun seenCapacity(): Int // max packets we sync per request (cap across types)
|
||||
fun gcsMaxBytes(): Int
|
||||
fun gcsTargetFpr(): Double // percent -> 0.0..1.0
|
||||
}
|
||||
|
||||
companion object { private const val TAG = "GossipSyncManager" }
|
||||
|
||||
var delegate: Delegate? = null
|
||||
|
||||
// Defaults (configurable constants)
|
||||
private val defaultMaxBytes = SyncDefaults.DEFAULT_FILTER_BYTES
|
||||
private val defaultFpr = SyncDefaults.DEFAULT_FPR_PERCENT
|
||||
|
||||
// Stored packets for sync:
|
||||
// - broadcast messages: keep up to seenCapacity() most recent, keyed by packetId
|
||||
private val messages = LinkedHashMap<String, BitchatPacket>()
|
||||
// - announcements: only keep latest per sender peerID
|
||||
private val latestAnnouncementByPeer = ConcurrentHashMap<String, Pair<String, BitchatPacket>>()
|
||||
|
||||
private var periodicJob: Job? = null
|
||||
fun start() {
|
||||
periodicJob?.cancel()
|
||||
periodicJob = scope.launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
try {
|
||||
delay(30_000)
|
||||
sendRequestSync()
|
||||
} catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { Log.e(TAG, "Periodic sync error: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
periodicJob?.cancel(); periodicJob = null
|
||||
}
|
||||
|
||||
fun scheduleInitialSync(delayMs: Long = 5_000L) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(delayMs)
|
||||
sendRequestSync()
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleInitialSyncToPeer(peerID: String, delayMs: Long = 5_000L) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(delayMs)
|
||||
sendRequestSyncToPeer(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
fun onPublicPacketSeen(packet: BitchatPacket) {
|
||||
// Only ANNOUNCE or broadcast MESSAGE
|
||||
val mt = MessageType.fromValue(packet.type)
|
||||
val isBroadcastMessage = (mt == MessageType.MESSAGE && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)))
|
||||
val isAnnouncement = (mt == MessageType.ANNOUNCE)
|
||||
if (!isBroadcastMessage && !isAnnouncement) return
|
||||
|
||||
val idBytes = PacketIdUtil.computeIdBytes(packet)
|
||||
val id = idBytes.joinToString("") { b -> "%02x".format(b) }
|
||||
|
||||
if (isBroadcastMessage) {
|
||||
synchronized(messages) {
|
||||
messages[id] = packet
|
||||
// Enforce capacity (remove oldest when exceeded)
|
||||
val cap = configProvider.seenCapacity().coerceAtLeast(1)
|
||||
while (messages.size > cap) {
|
||||
val it = messages.entries.iterator()
|
||||
if (it.hasNext()) { it.next(); it.remove() } else break
|
||||
}
|
||||
}
|
||||
} else if (isAnnouncement) {
|
||||
// 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
|
||||
// Enforce capacity (remove oldest when exceeded)
|
||||
val cap = configProvider.seenCapacity().coerceAtLeast(1)
|
||||
while (latestAnnouncementByPeer.size > cap) {
|
||||
val it = latestAnnouncementByPeer.entries.iterator()
|
||||
if (it.hasNext()) { it.next(); it.remove() } else break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendRequestSync() {
|
||||
val payload = buildGcsPayload()
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.REQUEST_SYNC.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = 0u // neighbors only
|
||||
)
|
||||
// Sign and broadcast
|
||||
val signed = delegate?.signPacketForBroadcast(packet) ?: packet
|
||||
delegate?.sendPacket(signed)
|
||||
}
|
||||
|
||||
private fun sendRequestSyncToPeer(peerID: String) {
|
||||
val payload = buildGcsPayload()
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.REQUEST_SYNC.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(peerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = 0u // neighbor only
|
||||
)
|
||||
Log.d(TAG, "Sending sync request to $peerID (${payload.size} bytes)")
|
||||
// Sign and send directly to peer
|
||||
val signed = delegate?.signPacketForBroadcast(packet) ?: packet
|
||||
delegate?.sendPacketToPeer(peerID, signed)
|
||||
}
|
||||
|
||||
fun handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
||||
// Decode GCS into sorted set for membership checks
|
||||
val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data)
|
||||
fun mightContain(id: ByteArray): Boolean {
|
||||
val v = (GCSFilter.run {
|
||||
// reuse hashing method from GCSFilter
|
||||
val md = java.security.MessageDigest.getInstance("SHA-256");
|
||||
md.update(id); val d = md.digest();
|
||||
var x = 0L; for (i in 0 until 8) { x = (x shl 8) or (d[i].toLong() and 0xFF) }
|
||||
(x and 0x7fff_ffff_ffff_ffffL) % request.m
|
||||
})
|
||||
return GCSFilter.contains(sorted, v)
|
||||
}
|
||||
|
||||
// 1) Announcements: send latest per peerID if remote doesn't have them
|
||||
for ((_, pair) in latestAnnouncementByPeer.entries) {
|
||||
val (id, pkt) = pair
|
||||
val idBytes = hexToBytes(id)
|
||||
if (!mightContain(idBytes)) {
|
||||
// Send original packet unchanged to requester only (keep local TTL)
|
||||
val toSend = pkt.copy(ttl = 0u)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Broadcast messages: send all they lack
|
||||
val toSendMsgs = synchronized(messages) { messages.values.toList() }
|
||||
for (pkt in toSendMsgs) {
|
||||
val idBytes = PacketIdUtil.computeIdBytes(pkt)
|
||||
if (!mightContain(idBytes)) {
|
||||
val toSend = pkt.copy(ttl = 0u)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hexStringToByteArray(hexString: String): ByteArray {
|
||||
val result = ByteArray(8) { 0 }
|
||||
var tempID = hexString
|
||||
var index = 0
|
||||
while (tempID.length >= 2 && index < 8) {
|
||||
val hexByte = tempID.substring(0, 2)
|
||||
val byte = hexByte.toIntOrNull(16)?.toByte()
|
||||
if (byte != null) result[index] = byte
|
||||
tempID = tempID.substring(2)
|
||||
index++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun hexToBytes(hex: String): ByteArray {
|
||||
val clean = if (hex.length % 2 == 0) hex else "0$hex"
|
||||
val out = ByteArray(clean.length / 2)
|
||||
var i = 0
|
||||
while (i < clean.length) {
|
||||
out[i/2] = clean.substring(i, i+2).toInt(16).toByte()
|
||||
i += 2
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun buildGcsPayload(): ByteArray {
|
||||
// Collect candidates: latest announcement per peer + recent broadcast messages
|
||||
val list = ArrayList<BitchatPacket>()
|
||||
// announcements
|
||||
for ((_, pair) in latestAnnouncementByPeer) {
|
||||
list.add(pair.second)
|
||||
}
|
||||
// messages
|
||||
synchronized(messages) {
|
||||
list.addAll(messages.values)
|
||||
}
|
||||
// sort by timestamp desc, then take up to min(seenCapacity, fit capacity)
|
||||
list.sortByDescending { it.timestamp.toLong() }
|
||||
|
||||
val maxBytes = try { configProvider.gcsMaxBytes() } catch (_: Exception) { defaultMaxBytes }
|
||||
val fpr = try { configProvider.gcsTargetFpr() } catch (_: Exception) { defaultFpr }
|
||||
val p = GCSFilter.deriveP(fpr)
|
||||
val nMax = GCSFilter.estimateMaxElementsForSize(maxBytes, p)
|
||||
val cap = configProvider.seenCapacity().coerceAtLeast(1)
|
||||
val takeN = minOf(nMax, cap, list.size)
|
||||
if (takeN <= 0) {
|
||||
val p0 = GCSFilter.deriveP(fpr)
|
||||
return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0)).encode()
|
||||
}
|
||||
val ids = list.take(takeN).map { pkt -> PacketIdUtil.computeIdBytes(pkt) }
|
||||
val params = GCSFilter.buildFilter(ids, maxBytes, fpr)
|
||||
val mVal = if (params.m <= 0L) 1 else params.m
|
||||
return RequestSyncPacket(p = params.p, m = mVal, data = params.data).encode()
|
||||
}
|
||||
|
||||
// Explicitly remove stored announcement for a given peer (hex ID)
|
||||
fun removeAnnouncementForPeer(peerID: String) {
|
||||
val key = peerID.lowercase()
|
||||
if (latestAnnouncementByPeer.remove(key) != null) {
|
||||
Log.d(TAG, "Removed stored announcement for peer $peerID")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Now remove the collected IDs
|
||||
for (id in idsToRemove) {
|
||||
messages.remove(id)
|
||||
}
|
||||
|
||||
if (idsToRemove.isNotEmpty()) {
|
||||
Log.d(TAG, "Pruned ${idsToRemove.size} messages with senders without announcements")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.bitchat.android.sync
|
||||
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Deterministic packet ID helper for sync purposes.
|
||||
* Uses SHA-256 over a canonical subset of packet fields:
|
||||
* [type | senderID | timestamp | payload] to generate a stable ID.
|
||||
* Returns a 16-byte (128-bit) truncated hash for compactness.
|
||||
*/
|
||||
object PacketIdUtil {
|
||||
fun computeIdBytes(packet: BitchatPacket): ByteArray {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
md.update(packet.type.toByte())
|
||||
md.update(packet.senderID)
|
||||
// Timestamp as 8 bytes big-endian
|
||||
val ts = packet.timestamp.toLong()
|
||||
for (i in 7 downTo 0) {
|
||||
md.update(((ts ushr (i * 8)) and 0xFF).toByte())
|
||||
}
|
||||
md.update(packet.payload)
|
||||
val digest = md.digest()
|
||||
return digest.copyOf(16) // 128-bit ID
|
||||
}
|
||||
|
||||
fun computeIdHex(packet: BitchatPacket): String {
|
||||
return computeIdBytes(packet).joinToString("") { b -> "%02x".format(b) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.bitchat.android.sync
|
||||
|
||||
object SyncDefaults {
|
||||
// Default values used when debug prefs are unavailable
|
||||
const val DEFAULT_FILTER_BYTES: Int = 256
|
||||
const val DEFAULT_FPR_PERCENT: Double = 1.0
|
||||
|
||||
// Receiver-side hard cap to avoid DoS (also enforced in RequestSyncPacket)
|
||||
const val MAX_ACCEPT_FILTER_BYTES: Int = 1024
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
@@ -16,6 +20,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.BaselineShift
|
||||
@@ -51,110 +56,194 @@ fun AboutSheet(
|
||||
// Bottom sheet state
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = false
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val isScrolled by remember {
|
||||
derivedStateOf {
|
||||
lazyListState.firstVisibleItemIndex > 0 || lazyListState.firstVisibleItemScrollOffset > 0
|
||||
}
|
||||
}
|
||||
val topBarAlpha by animateFloatAsState(
|
||||
targetValue = if (isScrolled) 0.95f else 0f,
|
||||
label = "topBarAlpha"
|
||||
)
|
||||
|
||||
// Color scheme matching LocationChannelsSheet
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val standardBlue = Color(0xFF007AFF) // iOS blue
|
||||
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green
|
||||
|
||||
if (isPresented) {
|
||||
ModalBottomSheet(
|
||||
modifier = modifier.statusBarsPadding(),
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
modifier = modifier
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
dragHandle = null
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Header
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = 80.dp, bottom = 20.dp)
|
||||
) {
|
||||
// Header Section
|
||||
item(key = "header") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "bitchat",
|
||||
fontSize = 18.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "v$versionName",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
baselineShift = BaselineShift(0.1f)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Text(
|
||||
text = "bitchat",
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "v$versionName",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
baselineShift = BaselineShift(0.1f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "decentralized mesh messaging with end-to-end encryption",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "decentralized mesh messaging with end-to-end encryption",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Features section
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
FeatureCard(
|
||||
icon = Icons.Filled.Bluetooth,
|
||||
iconColor = standardBlue,
|
||||
title = "offline mesh chat",
|
||||
description = "communicate directly via bluetooth le without internet or servers. messages relay through nearby devices to extend range.",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
FeatureCard(
|
||||
icon = Icons.Filled.Public,
|
||||
iconColor = standardGreen,
|
||||
title = "online geohash channels",
|
||||
description = "connect with people in your area using geohash-based channels. extend the mesh using public internet relays.",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
FeatureCard(
|
||||
icon = Icons.Filled.Lock,
|
||||
iconColor = if (isDark) Color(0xFFFFD60A) else Color(0xFFF5A623),
|
||||
title = "end-to-end encryption",
|
||||
description = "private messages are encrypted. channel messages are public.",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Appearance section (theme toggle)
|
||||
item {
|
||||
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState()
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Features section
|
||||
item(key = "feature_offline") {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Bluetooth,
|
||||
contentDescription = "Offline Mesh Chat",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Offline Mesh Chat",
|
||||
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.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "feature_geohash") {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Public,
|
||||
contentDescription = "Online Geohash Channels",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Online Geohash Channels",
|
||||
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.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "feature_encryption") {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = "End-to-End Encryption",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "End-to-End Encryption",
|
||||
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.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Appearance Section
|
||||
item(key = "appearance_section") {
|
||||
Text(
|
||||
text = "appearance",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 24.dp, bottom = 8.dp)
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = themePref.isSystem,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) },
|
||||
@@ -172,94 +261,181 @@ fun AboutSheet(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Proof of Work section
|
||||
item {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Initialize PoW preferences if not already done
|
||||
LaunchedEffect(Unit) {
|
||||
PoWPreferenceManager.init(context)
|
||||
}
|
||||
|
||||
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
|
||||
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Proof of Work Section
|
||||
item(key = "pow_section") {
|
||||
Text(
|
||||
text = "proof of work",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 24.dp, bottom = 8.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
LaunchedEffect(Unit) {
|
||||
PoWPreferenceManager.init(context)
|
||||
}
|
||||
|
||||
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
|
||||
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = !powEnabled,
|
||||
onClick = { PoWPreferenceManager.setPowEnabled(false) },
|
||||
label = { Text("pow off", fontFamily = FontFamily.Monospace) }
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
FilterChip(
|
||||
selected = !powEnabled,
|
||||
onClick = { PoWPreferenceManager.setPowEnabled(false) },
|
||||
label = { Text("pow off", fontFamily = FontFamily.Monospace) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = powEnabled,
|
||||
onClick = { PoWPreferenceManager.setPowEnabled(true) },
|
||||
label = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("pow on", fontFamily = FontFamily.Monospace)
|
||||
// Show current difficulty
|
||||
if (powEnabled) {
|
||||
Surface(
|
||||
color = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
|
||||
shape = RoundedCornerShape(50)
|
||||
) { Box(Modifier.size(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "add proof of work to geohash messages for spam deterrence.",
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
FilterChip(
|
||||
selected = powEnabled,
|
||||
onClick = { PoWPreferenceManager.setPowEnabled(true) },
|
||||
label = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
|
||||
// Show difficulty slider when enabled
|
||||
if (powEnabled) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "difficulty: $powDifficulty bits (~${NostrProofOfWork.estimateMiningTime(powDifficulty)})",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
|
||||
Slider(
|
||||
value = powDifficulty.toFloat(),
|
||||
onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) },
|
||||
valueRange = 0f..32f,
|
||||
steps = 33,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
|
||||
activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
)
|
||||
)
|
||||
|
||||
// Show difficulty description
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text("pow on", fontFamily = FontFamily.Monospace)
|
||||
// Show current difficulty
|
||||
if (powEnabled) {
|
||||
Surface(
|
||||
color = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
|
||||
shape = RoundedCornerShape(50)
|
||||
) { Box(Modifier.size(8.dp)) }
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "difficulty $powDifficulty requires ~${NostrProofOfWork.estimateWork(powDifficulty)} hash attempts",
|
||||
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"
|
||||
},
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Network (Tor) section
|
||||
item(key = "network_section") {
|
||||
val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(context)) }
|
||||
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
|
||||
Text(
|
||||
text = "add proof of work to geohash messages for spam deterrence.",
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
text = "network",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 24.dp, bottom = 8.dp)
|
||||
)
|
||||
|
||||
// Show difficulty slider when enabled
|
||||
if (powEnabled) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
Column(modifier = Modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "difficulty: $powDifficulty bits (~${NostrProofOfWork.estimateMiningTime(powDifficulty)})",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
FilterChip(
|
||||
selected = torMode.value == com.bitchat.android.net.TorMode.OFF,
|
||||
onClick = {
|
||||
torMode.value = com.bitchat.android.net.TorMode.OFF
|
||||
com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value)
|
||||
},
|
||||
label = { Text("tor off", fontFamily = FontFamily.Monospace) }
|
||||
)
|
||||
|
||||
Slider(
|
||||
value = powDifficulty.toFloat(),
|
||||
onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) },
|
||||
valueRange = 0f..32f,
|
||||
steps = 33, // 33 discrete values (0-32)
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
|
||||
activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
)
|
||||
FilterChip(
|
||||
selected = torMode.value == com.bitchat.android.net.TorMode.ON,
|
||||
onClick = {
|
||||
torMode.value = com.bitchat.android.net.TorMode.ON
|
||||
com.bitchat.android.net.TorPreferenceManager.set(context, torMode.value)
|
||||
},
|
||||
label = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("tor on", fontFamily = FontFamily.Monospace)
|
||||
val statusColor = when {
|
||||
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500)
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
else -> Color.Red
|
||||
}
|
||||
Surface(color = statusColor, shape = CircleShape) {
|
||||
Box(Modifier.size(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Show difficulty description
|
||||
}
|
||||
Text(
|
||||
text = "route internet over tor for enhanced privacy.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
if (torMode.value == com.bitchat.android.net.TorMode.ON) {
|
||||
val statusText = if (torStatus.running) "Running" else "Stopped"
|
||||
// Debug status (temporary)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
|
||||
@@ -267,204 +443,124 @@ fun AboutSheet(
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "difficulty $powDifficulty requires ~${NostrProofOfWork.estimateWork(powDifficulty)} hash attempts",
|
||||
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"
|
||||
},
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
text = "tor Status: $statusText, bootstrap ${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)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Network (Tor) section
|
||||
item {
|
||||
val ctx = LocalContext.current
|
||||
val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(ctx)) }
|
||||
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "network",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
FilterChip(
|
||||
selected = torMode.value == com.bitchat.android.net.TorMode.OFF,
|
||||
onClick = {
|
||||
torMode.value = com.bitchat.android.net.TorMode.OFF
|
||||
com.bitchat.android.net.TorPreferenceManager.set(ctx, torMode.value)
|
||||
},
|
||||
label = { Text("tor off", fontFamily = FontFamily.Monospace) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = torMode.value == com.bitchat.android.net.TorMode.ON,
|
||||
onClick = {
|
||||
torMode.value = com.bitchat.android.net.TorMode.ON
|
||||
com.bitchat.android.net.TorPreferenceManager.set(ctx, torMode.value)
|
||||
},
|
||||
label = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("tor on", fontFamily = FontFamily.Monospace)
|
||||
// Status indicator (red/orange/green) moved inside the "tor on" button
|
||||
val statusColor = when {
|
||||
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500)
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
else -> Color.Red
|
||||
}
|
||||
Surface(
|
||||
color = statusColor,
|
||||
shape = RoundedCornerShape(50)
|
||||
) { Box(Modifier.size(8.dp)) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "route internet over tor for enhanced privacy.",
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
// Emergency Warning Section
|
||||
item(key = "warning_section") {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val errorColor = colorScheme.error
|
||||
|
||||
// Debug status (temporary)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp, vertical = 24.dp)
|
||||
.fillMaxWidth(),
|
||||
color = errorColor.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Text(
|
||||
text = "tor status: " +
|
||||
(if (torStatus.running) "running" else "stopped") +
|
||||
", bootstrap=" + torStatus.bootstrapPercent + "%",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.75f)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = "Warning",
|
||||
tint = errorColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
val last = torStatus.lastLogLine
|
||||
if (last.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = "last: " + last.take(160),
|
||||
fontSize = 10.sp,
|
||||
text = "Emergency Data Deletion",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = errorColor
|
||||
)
|
||||
Text(
|
||||
text = "Tip: Triple-click the app title to emergency delete all stored data including messages, keys, and settings.",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emergency warning
|
||||
item {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.Red.copy(alpha = 0.08f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
|
||||
// Footer Section
|
||||
item(key = "footer") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = "Warning",
|
||||
tint = Color(0xFFBF1A1A),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = "emergency data deletion",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color(0xFFBF1A1A)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "tip: triple-click the app title to emergency delete all stored data including messages, keys, and settings.",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
if (onShowDebug != null) {
|
||||
TextButton(
|
||||
onClick = onShowDebug,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "Debug Settings",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debug settings button
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Debug button styled to match the app aesthetic
|
||||
TextButton(
|
||||
onClick = { onShowDebug?.invoke() },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "debug settings",
|
||||
text = "Open Source • Privacy First • Decentralized",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
)
|
||||
|
||||
// Add extra space at bottom for gesture area
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Version and footer space
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
// TopBar
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "open source • privacy first • decentralized",
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
text = "Close",
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
// Add extra space at bottom for gesture area
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -472,78 +568,6 @@ fun AboutSheet(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureCard(
|
||||
icon: ImageVector,
|
||||
iconColor: Color,
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
tint = iconColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
lineHeight = 15.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureItem(text: String) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Text(
|
||||
text = "•",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Password prompt dialog for password-protected channels
|
||||
* Kept as dialog since it requires user input
|
||||
|
||||
@@ -518,7 +518,12 @@ private fun MainHeader(
|
||||
val isConnected by viewModel.isConnected.observeAsState(false)
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
||||
|
||||
|
||||
// Bookmarks store for current geohash toggle (iOS parity)
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
|
||||
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -565,11 +570,36 @@ private fun MainHeader(
|
||||
)
|
||||
}
|
||||
|
||||
// Location channels button (matching iOS implementation)
|
||||
LocationChannelsButton(
|
||||
viewModel = viewModel,
|
||||
onClick = onLocationChannelsClick
|
||||
)
|
||||
// Location channels button (matching iOS implementation) and bookmark grouped tightly
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 14.dp)) {
|
||||
LocationChannelsButton(
|
||||
viewModel = viewModel,
|
||||
onClick = onLocationChannelsClick
|
||||
)
|
||||
|
||||
// Bookmark toggle for current geohash (not shown for mesh)
|
||||
val currentGeohash: String? = when (val sc = selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> sc.channel.geohash
|
||||
else -> null
|
||||
}
|
||||
if (currentGeohash != null) {
|
||||
val isBookmarked = bookmarks.contains(currentGeohash)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 1.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",
|
||||
tint = if (isBookmarked) Color(0xFF00C851) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tor status cable icon when Tor is enabled
|
||||
TorStatusIcon(modifier = Modifier.size(14.dp))
|
||||
@@ -621,7 +651,7 @@ private fun LocationChannelsButton(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = badgeColor
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 2.dp)
|
||||
contentPadding = PaddingValues(start = 4.dp, end = 0.dp, top = 2.dp, bottom = 2.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
package com.bitchat.android.ui
|
||||
// [Goose] Bridge file share events to ViewModel via dispatcher is installed in ChatScreen composition
|
||||
|
||||
// [Goose] Installing FileShareDispatcher handler in ChatScreen to forward file sends to ViewModel
|
||||
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
@@ -21,6 +25,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.ui.media.FullScreenImageViewer
|
||||
|
||||
/**
|
||||
* Main ChatScreen - REFACTORED to use component-based architecture
|
||||
@@ -60,6 +65,9 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
var showUserSheet by remember { mutableStateOf(false) }
|
||||
var selectedUserForSheet by remember { mutableStateOf("") }
|
||||
var selectedMessageForSheet by remember { mutableStateOf<BitchatMessage?>(null) }
|
||||
var showFullScreenImageViewer by remember { mutableStateOf(false) }
|
||||
var viewerImagePaths by remember { mutableStateOf(emptyList<String>()) }
|
||||
var initialViewerIndex by remember { mutableStateOf(0) }
|
||||
var forceScrollToBottom by remember { mutableStateOf(false) }
|
||||
var isScrolledUp by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -154,28 +162,53 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
selectedUserForSheet = baseName
|
||||
selectedMessageForSheet = message
|
||||
showUserSheet = true
|
||||
},
|
||||
onCancelTransfer = { msg ->
|
||||
viewModel.cancelMediaSend(msg.id)
|
||||
},
|
||||
onImageClick = { currentPath, allImagePaths, initialIndex ->
|
||||
viewerImagePaths = allImagePaths
|
||||
initialViewerIndex = initialIndex
|
||||
showFullScreenImageViewer = true
|
||||
}
|
||||
)
|
||||
// Input area - stays at bottom
|
||||
ChatInputSection(
|
||||
messageText = messageText,
|
||||
onMessageTextChange = { newText: TextFieldValue ->
|
||||
messageText = newText
|
||||
viewModel.updateCommandSuggestions(newText.text)
|
||||
viewModel.updateMentionSuggestions(newText.text)
|
||||
},
|
||||
onSend = {
|
||||
if (messageText.text.trim().isNotEmpty()) {
|
||||
viewModel.sendMessage(messageText.text.trim())
|
||||
messageText = TextFieldValue("")
|
||||
forceScrollToBottom = !forceScrollToBottom // Toggle to trigger scroll
|
||||
}
|
||||
},
|
||||
showCommandSuggestions = showCommandSuggestions,
|
||||
commandSuggestions = commandSuggestions,
|
||||
showMentionSuggestions = showMentionSuggestions,
|
||||
mentionSuggestions = mentionSuggestions,
|
||||
onCommandSuggestionClick = { suggestion: CommandSuggestion ->
|
||||
// Bridge file share from lower-level input to ViewModel
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
com.bitchat.android.ui.events.FileShareDispatcher.setHandler { peer, channel, path ->
|
||||
viewModel.sendFileNote(peer, channel, path)
|
||||
}
|
||||
}
|
||||
|
||||
ChatInputSection(
|
||||
messageText = messageText,
|
||||
onMessageTextChange = { newText: TextFieldValue ->
|
||||
messageText = newText
|
||||
viewModel.updateCommandSuggestions(newText.text)
|
||||
viewModel.updateMentionSuggestions(newText.text)
|
||||
},
|
||||
onSend = {
|
||||
if (messageText.text.trim().isNotEmpty()) {
|
||||
viewModel.sendMessage(messageText.text.trim())
|
||||
messageText = TextFieldValue("")
|
||||
forceScrollToBottom = !forceScrollToBottom // Toggle to trigger scroll
|
||||
}
|
||||
},
|
||||
onSendVoiceNote = { peer, onionOrChannel, path ->
|
||||
viewModel.sendVoiceNote(peer, onionOrChannel, path)
|
||||
},
|
||||
onSendImageNote = { peer, onionOrChannel, path ->
|
||||
viewModel.sendImageNote(peer, onionOrChannel, path)
|
||||
},
|
||||
onSendFileNote = { peer, onionOrChannel, path ->
|
||||
viewModel.sendFileNote(peer, onionOrChannel, path)
|
||||
},
|
||||
|
||||
showCommandSuggestions = showCommandSuggestions,
|
||||
commandSuggestions = commandSuggestions,
|
||||
showMentionSuggestions = showMentionSuggestions,
|
||||
mentionSuggestions = mentionSuggestions,
|
||||
onCommandSuggestionClick = { suggestion: CommandSuggestion ->
|
||||
val commandText = viewModel.selectCommandSuggestion(suggestion)
|
||||
messageText = TextFieldValue(
|
||||
text = commandText,
|
||||
@@ -288,6 +321,15 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
// Full-screen image viewer - separate from other sheets to allow image browsing without navigation
|
||||
if (showFullScreenImageViewer) {
|
||||
FullScreenImageViewer(
|
||||
imagePaths = viewerImagePaths,
|
||||
initialIndex = initialViewerIndex,
|
||||
onClose = { showFullScreenImageViewer = false }
|
||||
)
|
||||
}
|
||||
|
||||
// Dialogs and Sheets
|
||||
ChatDialogs(
|
||||
showPasswordDialog = showPasswordDialog,
|
||||
@@ -327,6 +369,9 @@ private fun ChatInputSection(
|
||||
messageText: TextFieldValue,
|
||||
onMessageTextChange: (TextFieldValue) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onSendVoiceNote: (String?, String?, String) -> Unit,
|
||||
onSendImageNote: (String?, String?, String) -> Unit,
|
||||
onSendFileNote: (String?, String?, String) -> Unit,
|
||||
showCommandSuggestions: Boolean,
|
||||
commandSuggestions: List<CommandSuggestion>,
|
||||
showMentionSuggestions: Boolean,
|
||||
@@ -351,10 +396,8 @@ private fun ChatInputSection(
|
||||
onSuggestionClick = onCommandSuggestionClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
|
||||
}
|
||||
|
||||
// Mention suggestions box
|
||||
if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) {
|
||||
MentionSuggestionsBox(
|
||||
@@ -362,14 +405,15 @@ private fun ChatInputSection(
|
||||
onSuggestionClick = onMentionSuggestionClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
|
||||
}
|
||||
|
||||
MessageInput(
|
||||
value = messageText,
|
||||
onValueChange = onMessageTextChange,
|
||||
onSend = onSend,
|
||||
onSendVoiceNote = onSendVoiceNote,
|
||||
onSendImageNote = onSendImageNote,
|
||||
onSendFileNote = onSendFileNote,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
@@ -378,7 +422,6 @@ private fun ChatInputSection(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ChatFloatingHeader(
|
||||
|
||||
@@ -156,6 +156,105 @@ fun formatMessageAsAnnotatedString(
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build only the nickname + timestamp header line for a message, matching styles of normal messages.
|
||||
*/
|
||||
fun formatMessageHeaderAnnotatedString(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
val isSelf = message.senderPeerID == meshService.myPeerID ||
|
||||
message.sender == currentUserNickname ||
|
||||
message.sender.startsWith("$currentUserNickname#")
|
||||
|
||||
if (message.sender != "system") {
|
||||
val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||
val (baseName, suffix) = splitSuffix(message.sender)
|
||||
|
||||
// "<@"
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append("<@")
|
||||
builder.pop()
|
||||
|
||||
// Base name (clickable when not self)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
val nicknameStart = builder.length
|
||||
builder.append(truncateNickname(baseName))
|
||||
val nicknameEnd = builder.length
|
||||
if (!isSelf) {
|
||||
builder.addStringAnnotation(
|
||||
tag = "nickname_click",
|
||||
annotation = (message.originalSender ?: message.sender),
|
||||
start = nicknameStart,
|
||||
end = nicknameEnd
|
||||
)
|
||||
}
|
||||
builder.pop()
|
||||
|
||||
// Hashtag suffix
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor.copy(alpha = 0.6f),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append(suffix)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
// Sender suffix ">"
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append(">")
|
||||
builder.pop()
|
||||
|
||||
// Timestamp and optional PoW bits, matching normal message appearance
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.7f),
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
message.powDifficulty?.let { bits ->
|
||||
if (bits > 0) builder.append(" ⛨${bits}b")
|
||||
}
|
||||
builder.pop()
|
||||
} else {
|
||||
// System message header (should rarely apply to voice)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray,
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
))
|
||||
builder.append("* ${message.content} *")
|
||||
builder.pop()
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.5f),
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS-style peer color assignment using djb2 hash algorithm
|
||||
* Avoids orange (~30°) reserved for self messages
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
|
||||
|
||||
@@ -33,21 +34,37 @@ class ChatViewModel(
|
||||
private const val TAG = "ChatViewModel"
|
||||
}
|
||||
|
||||
// State management
|
||||
fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
|
||||
mediaSendingManager.sendVoiceNote(toPeerIDOrNull, channelOrNull, filePath)
|
||||
}
|
||||
|
||||
fun sendFileNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
|
||||
mediaSendingManager.sendFileNote(toPeerIDOrNull, channelOrNull, filePath)
|
||||
}
|
||||
|
||||
fun sendImageNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
|
||||
mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath)
|
||||
}
|
||||
|
||||
// MARK: - State management
|
||||
private val state = ChatState()
|
||||
|
||||
|
||||
// Transfer progress tracking
|
||||
private val transferMessageMap = mutableMapOf<String, String>()
|
||||
private val messageTransferMap = mutableMapOf<String, String>()
|
||||
|
||||
// Specialized managers
|
||||
private val dataManager = DataManager(application.applicationContext)
|
||||
private val messageManager = MessageManager(state)
|
||||
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
|
||||
|
||||
|
||||
// Create Noise session delegate for clean dependency injection
|
||||
private val noiseSessionDelegate = object : NoiseSessionDelegate {
|
||||
override fun hasEstablishedSession(peerID: String): Boolean = meshService.hasEstablishedSession(peerID)
|
||||
override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID)
|
||||
override fun initiateHandshake(peerID: String) = meshService.initiateNoiseHandshake(peerID)
|
||||
override fun getMyPeerID(): String = meshService.myPeerID
|
||||
}
|
||||
|
||||
|
||||
val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate)
|
||||
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
|
||||
private val notificationManager = NotificationManager(
|
||||
@@ -55,6 +72,9 @@ class ChatViewModel(
|
||||
NotificationManagerCompat.from(application.applicationContext),
|
||||
NotificationIntervalManager()
|
||||
)
|
||||
|
||||
// Media file sending manager
|
||||
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager, meshService)
|
||||
|
||||
// Delegate handler for mesh callbacks
|
||||
private val meshDelegateHandler = MeshDelegateHandler(
|
||||
@@ -121,6 +141,27 @@ class ChatViewModel(
|
||||
init {
|
||||
// Note: Mesh service delegate is now set by MainActivity
|
||||
loadAndInitialize()
|
||||
// Subscribe to BLE transfer progress and reflect in message deliveryStatus
|
||||
viewModelScope.launch {
|
||||
com.bitchat.android.mesh.TransferProgressManager.events.collect { evt ->
|
||||
mediaSendingManager.handleTransferProgressEvent(evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAndInitialize() {
|
||||
@@ -199,6 +240,8 @@ class ChatViewModel(
|
||||
messageManager.addMessage(welcomeMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// BLE receives are inserted by MessageHandler path; no VoiceNoteBus for Tor in this branch.
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
@@ -719,8 +762,14 @@ class ChatViewModel(
|
||||
// Clear all notifications
|
||||
notificationManager.clearAllNotifications()
|
||||
|
||||
// Clear Nostr/geohash state, keys, connections, and reinitialize from scratch
|
||||
// Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch
|
||||
try {
|
||||
// Clear geohash bookmarks too (panic should remove everything)
|
||||
try {
|
||||
val store = com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication())
|
||||
store.clearAll()
|
||||
} catch (_: Exception) { }
|
||||
|
||||
geohashViewModel.panicReset()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
|
||||
|
||||
@@ -30,10 +30,25 @@ class GeohashViewModel(
|
||||
|
||||
companion object { private const val TAG = "GeohashViewModel" }
|
||||
|
||||
private val repo = GeohashRepository(application, state)
|
||||
private val repo = GeohashRepository(application, state, dataManager)
|
||||
private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope)
|
||||
private val geohashMessageHandler = GeohashMessageHandler(application, state, messageManager, repo, viewModelScope)
|
||||
private val dmHandler = NostrDirectMessageHandler(application, state, privateChatManager, meshDelegateHandler, viewModelScope, repo)
|
||||
private val geohashMessageHandler = GeohashMessageHandler(
|
||||
application = application,
|
||||
state = state,
|
||||
messageManager = messageManager,
|
||||
repo = repo,
|
||||
scope = viewModelScope,
|
||||
dataManager = dataManager
|
||||
)
|
||||
private val dmHandler = NostrDirectMessageHandler(
|
||||
application = application,
|
||||
state = state,
|
||||
privateChatManager = privateChatManager,
|
||||
meshDelegateHandler = meshDelegateHandler,
|
||||
scope = viewModelScope,
|
||||
repo = repo,
|
||||
dataManager = dataManager
|
||||
)
|
||||
|
||||
private var currentGeohashSubId: String? = null
|
||||
private var currentDmSubId: String? = null
|
||||
@@ -99,14 +114,22 @@ class GeohashViewModel(
|
||||
powDifficulty = if (pow.enabled) pow.difficulty else null
|
||||
)
|
||||
messageManager.addChannelMessage("geo:${channel.geohash}", localMsg)
|
||||
if (pow.enabled && pow.difficulty > 0) {
|
||||
val startedMining = pow.enabled && pow.difficulty > 0
|
||||
if (startedMining) {
|
||||
com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempId)
|
||||
}
|
||||
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
|
||||
val teleported = state.isTeleported.value ?: false
|
||||
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
|
||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
|
||||
try {
|
||||
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
|
||||
val teleported = state.isTeleported.value ?: false
|
||||
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
|
||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
|
||||
} finally {
|
||||
// Ensure we stop the per-message mining animation regardless of success/failure
|
||||
if (startedMining) {
|
||||
com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage(tempId)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send geohash message: ${e.message}")
|
||||
}
|
||||
@@ -136,25 +159,6 @@ class GeohashViewModel(
|
||||
fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) {
|
||||
val convKey = "nostr_${pubkeyHex.take(16)}"
|
||||
repo.putNostrKeyMapping(convKey, pubkeyHex)
|
||||
onStartPrivateChat(convKey)
|
||||
Log.d(TAG, "🗨️ Started geohash DM with $pubkeyHex -> $convKey")
|
||||
}
|
||||
|
||||
fun getNostrKeyMapping(): Map<String, String> = repo.getNostrKeyMapping()
|
||||
|
||||
fun blockUserInGeohash(targetNickname: String) {
|
||||
val pubkey = repo.findPubkeyByNickname(targetNickname)
|
||||
if (pubkey != null) {
|
||||
dataManager.addGeohashBlockedUser(pubkey)
|
||||
val sysMsg = com.bitchat.android.model.BitchatMessage(
|
||||
sender = "system",
|
||||
content = "blocked $targetNickname in geohash channels",
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) {
|
||||
val convKey = "nostr_${'$'}{pubkeyHex.take(16)}"
|
||||
repo.putNostrKeyMapping(convKey, pubkeyHex)
|
||||
// Record the conversation's geohash using the currently selected location channel (if any)
|
||||
val current = state.selectedLocationChannel.value
|
||||
val gh = (current as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
|
||||
@@ -163,9 +167,24 @@ class GeohashViewModel(
|
||||
com.bitchat.android.nostr.GeohashConversationRegistry.set(convKey, gh)
|
||||
}
|
||||
onStartPrivateChat(convKey)
|
||||
Log.d(TAG, "🗨️ Started geohash DM with ${'$'}pubkeyHex -> ${'$'}convKey (geohash=${'$'}gh)")
|
||||
Log.d(TAG, "🗨️ Started geohash DM with ${pubkeyHex} -> ${convKey} (geohash=${gh})")
|
||||
}
|
||||
|
||||
fun getNostrKeyMapping(): Map<String, String> = repo.getNostrKeyMapping()
|
||||
|
||||
fun blockUserInGeohash(targetNickname: String) {
|
||||
val pubkey = repo.findPubkeyByNickname(targetNickname)
|
||||
if (pubkey != null) {
|
||||
dataManager.addGeohashBlockedUser(pubkey)
|
||||
// Refresh people list and counts to remove blocked entry immediately
|
||||
repo.refreshGeohashPeople()
|
||||
repo.updateReactiveParticipantCounts()
|
||||
val sysMsg = com.bitchat.android.model.BitchatMessage(
|
||||
sender = "system",
|
||||
content = "blocked $targetNickname in geohash channels",
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
messageManager.addMessage(sysMsg)
|
||||
} else {
|
||||
val sysMsg = com.bitchat.android.model.BitchatMessage(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
package com.bitchat.android.ui
|
||||
// [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher
|
||||
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -16,7 +18,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
@@ -24,7 +25,6 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
@@ -33,9 +33,16 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.R
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import com.bitchat.android.features.voice.normalizeAmplitudeSample
|
||||
import com.bitchat.android.features.voice.AudioWaveformExtractor
|
||||
import com.bitchat.android.ui.media.RealtimeScrollingWaveform
|
||||
import com.bitchat.android.ui.media.ImagePickerButton
|
||||
import com.bitchat.android.ui.media.FilePickerButton
|
||||
|
||||
/**
|
||||
* Input components for ChatScreen
|
||||
@@ -157,6 +164,9 @@ fun MessageInput(
|
||||
value: TextFieldValue,
|
||||
onValueChange: (TextFieldValue) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onSendVoiceNote: (String?, String?, String) -> Unit,
|
||||
onSendImageNote: (String?, String?, String) -> Unit,
|
||||
onSendFileNote: (String?, String?, String) -> Unit,
|
||||
selectedPrivatePeer: String?,
|
||||
currentChannel: String?,
|
||||
nickname: String,
|
||||
@@ -165,16 +175,22 @@ fun MessageInput(
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isFocused = remember { mutableStateOf(false) }
|
||||
val hasText = value.text.isNotBlank() // Check if there's text for send button state
|
||||
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var isRecording by remember { mutableStateOf(false) }
|
||||
var elapsedMs by remember { mutableStateOf(0L) }
|
||||
var amplitude by remember { mutableStateOf(0) }
|
||||
|
||||
Row(
|
||||
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Text input with placeholder
|
||||
// Text input with placeholder OR visualizer when recording
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
// Always keep the text field mounted to retain focus and avoid IME collapse
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
@@ -182,7 +198,7 @@ fun MessageInput(
|
||||
color = colorScheme.primary,
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
cursorBrush = SolidColor(colorScheme.primary),
|
||||
cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = {
|
||||
if (hasText) onSend() // Only send if there's text
|
||||
@@ -192,13 +208,14 @@ fun MessageInput(
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
isFocused.value = focusState.isFocused
|
||||
}
|
||||
)
|
||||
|
||||
// Show placeholder when there's no text
|
||||
if (value.text.isEmpty()) {
|
||||
|
||||
// Show placeholder when there's no text and not recording
|
||||
if (value.text.isEmpty() && !isRecording) {
|
||||
Text(
|
||||
text = "type a message...",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
@@ -208,23 +225,94 @@ fun MessageInput(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// Overlay the real-time scrolling waveform while recording
|
||||
if (isRecording) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
RealtimeScrollingWaveform(
|
||||
modifier = Modifier.weight(1f).height(32.dp),
|
||||
amplitudeNorm = normalizeAmplitudeSample(amplitude)
|
||||
)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
val secs = (elapsedMs / 1000).toInt()
|
||||
val mm = secs / 60
|
||||
val ss = secs % 60
|
||||
val maxSecs = 10 // 10 second max recording time
|
||||
val maxMm = maxSecs / 60
|
||||
val maxSs = maxSecs % 60
|
||||
Text(
|
||||
text = String.format("%02d:%02d / %02d:%02d", mm, ss, maxMm, maxSs),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.primary,
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing
|
||||
|
||||
// Command quick access button
|
||||
// Voice and image buttons when no text (always visible for mesh + channels + private)
|
||||
if (value.text.isEmpty()) {
|
||||
FilledTonalIconButton(
|
||||
onClick = {
|
||||
onValueChange(TextFieldValue(text = "/", selection = TextRange("/".length)))
|
||||
},
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "/",
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
// Hold-to-record microphone
|
||||
val bg = if (colorScheme.background == Color.Black) Color(0xFF00FF00).copy(alpha = 0.75f) else Color(0xFF008000).copy(alpha = 0.75f)
|
||||
|
||||
// Ensure latest values are used when finishing recording
|
||||
val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer)
|
||||
val latestChannel = rememberUpdatedState(currentChannel)
|
||||
val latestOnSendVoiceNote = rememberUpdatedState(onSendVoiceNote)
|
||||
|
||||
// Image button (image picker) - hide during recording
|
||||
if (!isRecording) {
|
||||
// Revert to original separate buttons: round File button (left) and the old Image plus button (right)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
// DISABLE FILE PICKER
|
||||
//FilePickerButton(
|
||||
// onFileReady = { path ->
|
||||
// onSendFileNote(latestSelectedPeer.value, latestChannel.value, path)
|
||||
// }
|
||||
//)
|
||||
ImagePickerButton(
|
||||
onImageReady = { outPath ->
|
||||
onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(1.dp))
|
||||
|
||||
VoiceRecordButton(
|
||||
backgroundColor = bg,
|
||||
onStart = {
|
||||
isRecording = true
|
||||
elapsedMs = 0L
|
||||
// Keep existing focus to avoid IME collapse, but do not force-show keyboard
|
||||
if (isFocused.value) {
|
||||
try { focusRequester.requestFocus() } catch (_: Exception) {}
|
||||
}
|
||||
},
|
||||
onAmplitude = { amp, ms ->
|
||||
amplitude = amp
|
||||
elapsedMs = ms
|
||||
},
|
||||
onFinish = { path ->
|
||||
isRecording = false
|
||||
// Extract and cache waveform from the actual audio file to match receiver rendering
|
||||
AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr ->
|
||||
if (arr != null) {
|
||||
try { com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr) } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
// BLE path (private or public) — use latest values to avoid stale captures
|
||||
latestOnSendVoiceNote.value(
|
||||
latestSelectedPeer.value,
|
||||
latestChannel.value,
|
||||
path
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
} else {
|
||||
// Send button with enabled/disabled state
|
||||
IconButton(
|
||||
@@ -272,6 +360,8 @@ fun MessageInput(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-stop handled inside VoiceRecordButton
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -3,17 +3,20 @@ package com.bitchat.android.ui
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Map
|
||||
import androidx.compose.material.icons.filled.PinDrop
|
||||
import androidx.compose.material.icons.outlined.BookmarkBorder
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -22,17 +25,18 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
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 kotlinx.coroutines.launch
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import kotlinx.coroutines.launch
|
||||
import com.bitchat.android.geohash.GeohashChannel
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import java.util.*
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.bitchat.android.geohash.GeohashBookmarksStore
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
|
||||
/**
|
||||
* Location Channels Sheet for selecting geohash-based location channels
|
||||
@@ -48,32 +52,45 @@ fun LocationChannelsSheet(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val locationManager = LocationChannelManager.getInstance(context)
|
||||
|
||||
val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) }
|
||||
|
||||
// Observe location manager state
|
||||
val permissionState by locationManager.permissionState.observeAsState()
|
||||
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
|
||||
val selectedChannel by locationManager.selectedChannel.observeAsState()
|
||||
val teleported by locationManager.teleported.observeAsState(false)
|
||||
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
||||
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
|
||||
|
||||
// CRITICAL FIX: Observe reactive participant counts for real-time updates
|
||||
|
||||
// Observe bookmarks state
|
||||
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
|
||||
val bookmarkNames by bookmarksStore.bookmarkNames.observeAsState(emptyMap())
|
||||
|
||||
// Observe reactive participant counts
|
||||
val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap())
|
||||
|
||||
|
||||
// UI state
|
||||
var customGeohash by remember { mutableStateOf("") }
|
||||
var customError by remember { mutableStateOf<String?>(null) }
|
||||
var isInputFocused by remember { mutableStateOf(false) }
|
||||
|
||||
|
||||
// Bottom sheet state
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = isInputFocused
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// Scroll state for LazyColumn
|
||||
|
||||
// Scroll state for LazyColumn with animated top bar
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val isScrolled by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
||||
}
|
||||
}
|
||||
val topBarAlpha by animateFloatAsState(
|
||||
targetValue = if (isScrolled) 0.95f else 0f,
|
||||
label = "topBarAlpha"
|
||||
)
|
||||
|
||||
val mapPickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
@@ -85,139 +102,148 @@ fun LocationChannelsSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// iOS system colors (matches iOS exactly)
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green
|
||||
val standardBlue = Color(0xFF007AFF) // iOS blue
|
||||
|
||||
|
||||
if (isPresented) {
|
||||
ModalBottomSheet(
|
||||
modifier = modifier.statusBarsPadding(),
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
modifier = modifier
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
dragHandle = null
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (isInputFocused) {
|
||||
Modifier.fillMaxHeight().padding(horizontal = 16.dp, vertical = 24.dp)
|
||||
} else {
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = 48.dp, bottom = 16.dp)
|
||||
) {
|
||||
// Header Section
|
||||
item(key = "header") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "#location channels",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
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.",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Header
|
||||
Text(
|
||||
text = "#location channels",
|
||||
fontSize = 18.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
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.",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
|
||||
// Location Services Control - Show permission handling if enabled
|
||||
if (locationServicesEnabled) {
|
||||
when (permissionState) {
|
||||
LocationChannelManager.PermissionState.NOT_DETERMINED -> {
|
||||
Button(
|
||||
onClick = { locationManager.enableLocationChannels() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = standardGreen.copy(alpha = 0.12f),
|
||||
contentColor = standardGreen
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
}
|
||||
|
||||
// Permission controls if services enabled
|
||||
if (locationServicesEnabled) {
|
||||
item(key = "permissions") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "grant location permission",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LocationChannelManager.PermissionState.DENIED,
|
||||
LocationChannelManager.PermissionState.RESTRICTED -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "location permission denied. enable in settings to use location channels.",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color.Red.copy(alpha = 0.8f)
|
||||
)
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
when (permissionState) {
|
||||
LocationChannelManager.PermissionState.NOT_DETERMINED -> {
|
||||
Button(
|
||||
onClick = { locationManager.enableLocationChannels() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = standardGreen.copy(alpha = 0.12f),
|
||||
contentColor = standardGreen
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "grant location permission",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
LocationChannelManager.PermissionState.DENIED,
|
||||
LocationChannelManager.PermissionState.RESTRICTED -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = "location permission denied. enable in settings to use location channels.",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color.Red.copy(alpha = 0.8f)
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = "open settings",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
LocationChannelManager.PermissionState.AUTHORIZED -> {
|
||||
Text(
|
||||
text = "✓ location permission granted",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = standardGreen
|
||||
)
|
||||
}
|
||||
null -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(12.dp))
|
||||
Text(
|
||||
text = "checking permissions...",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = "open settings",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LocationChannelManager.PermissionState.AUTHORIZED -> {
|
||||
Text(
|
||||
text = "✓ location permission granted",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = standardGreen
|
||||
)
|
||||
}
|
||||
|
||||
null -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(12.dp))
|
||||
Text(
|
||||
text = "checking permissions...",
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channel list (iOS-style plain list)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
|
||||
// Mesh option first
|
||||
item {
|
||||
item(key = "mesh") {
|
||||
ChannelRow(
|
||||
title = meshTitleWithCount(viewModel),
|
||||
subtitle = "#bluetooth • ${bluetoothRangeString()}",
|
||||
isSelected = selectedChannel is ChannelID.Mesh,
|
||||
titleColor = standardBlue,
|
||||
titleBold = meshCount(viewModel) > 0,
|
||||
trailingContent = null,
|
||||
onClick = {
|
||||
locationManager.select(ChannelID.Mesh)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Nearby options (only show if location services are enabled)
|
||||
if (availableChannels.isNotEmpty() && locationServicesEnabled) {
|
||||
items(availableChannels) { channel ->
|
||||
@@ -225,16 +251,25 @@ fun LocationChannelsSheet(
|
||||
val nameBase = locationNames[channel.level]
|
||||
val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
|
||||
val subtitlePrefix = "#${channel.geohash} • $coverage"
|
||||
// CRITICAL FIX: Use reactive participant count from LiveData
|
||||
val participantCount = geohashParticipantCounts[channel.geohash] ?: 0
|
||||
val highlight = participantCount > 0
|
||||
|
||||
val isBookmarked = bookmarksStore.isBookmarked(channel.geohash)
|
||||
|
||||
ChannelRow(
|
||||
title = geohashTitleWithCount(channel, participantCount),
|
||||
subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""),
|
||||
isSelected = isChannelSelected(channel, selectedChannel),
|
||||
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),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
// Selecting a suggested nearby channel is not a teleport
|
||||
locationManager.setTeleported(false)
|
||||
@@ -246,7 +281,7 @@ fun LocationChannelsSheet(
|
||||
} else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
@@ -258,229 +293,311 @@ fun LocationChannelsSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Bookmarked geohashes
|
||||
if (bookmarks.isNotEmpty()) {
|
||||
item(key = "bookmarked_header") {
|
||||
Text(
|
||||
text = "bookmarked",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
items(bookmarks) { gh ->
|
||||
val level = levelForLength(gh.length)
|
||||
val channel = GeohashChannel(level = level, geohash = gh)
|
||||
val coverage = coverageString(gh.length)
|
||||
val subtitlePrefix = "#${gh} • $coverage"
|
||||
val name = bookmarkNames[gh]
|
||||
val subtitle = subtitlePrefix + (name?.let { " • ${formattedNamePrefix(level)}$it" } ?: "")
|
||||
val participantCount = geohashParticipantCounts[gh] ?: 0
|
||||
val title = geohashHashTitleWithCount(gh, participantCount)
|
||||
|
||||
ChannelRow(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
isSelected = isChannelSelected(channel, selectedChannel),
|
||||
titleColor = null,
|
||||
titleBold = participantCount > 0,
|
||||
trailingContent = {
|
||||
IconButton(onClick = { bookmarksStore.toggle(gh) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Bookmark,
|
||||
contentDescription = "Remove bookmark",
|
||||
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
// For bookmarked selection, mark teleported based on regional membership
|
||||
val inRegional = availableChannels.any { it.geohash == gh }
|
||||
if (!inRegional && availableChannels.isNotEmpty()) {
|
||||
locationManager.setTeleported(true)
|
||||
} else {
|
||||
locationManager.setTeleported(false)
|
||||
}
|
||||
locationManager.select(ChannelID.Location(channel))
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
LaunchedEffect(gh) { bookmarksStore.resolveNameIfNeeded(gh) }
|
||||
}
|
||||
}
|
||||
|
||||
// Custom geohash teleport (iOS-style inline form)
|
||||
item {
|
||||
item(key = "custom_geohash") {
|
||||
Surface(
|
||||
color = Color.Transparent,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
color = Color.Transparent
|
||||
.padding(horizontal = 24.dp, vertical = 2.dp)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "#",
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "#",
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
BasicTextField(
|
||||
value = customGeohash,
|
||||
onValueChange = { newValue ->
|
||||
// iOS-style geohash validation (base32 characters only)
|
||||
val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
|
||||
val filtered = newValue
|
||||
.lowercase()
|
||||
.replace("#", "")
|
||||
.filter { it in allowed }
|
||||
.take(12)
|
||||
|
||||
customGeohash = filtered
|
||||
customError = null
|
||||
},
|
||||
textStyle = androidx.compose.ui.text.TextStyle(
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
BasicTextField(
|
||||
value = customGeohash,
|
||||
onValueChange = { newValue ->
|
||||
// iOS-style geohash validation (base32 characters only)
|
||||
val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
|
||||
val filtered = newValue
|
||||
.lowercase()
|
||||
.replace("#", "")
|
||||
.filter { it in allowed }
|
||||
.take(12)
|
||||
|
||||
customGeohash = filtered
|
||||
customError = null
|
||||
},
|
||||
textStyle = androidx.compose.ui.text.TextStyle(
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.onFocusChanged { focusState ->
|
||||
isInputFocused = focusState.isFocused
|
||||
if (focusState.isFocused) {
|
||||
coroutineScope.launch {
|
||||
sheetState.expand()
|
||||
// Scroll to bottom to show input and remove button
|
||||
listState.animateScrollToItem(
|
||||
index = listState.layoutInfo.totalItemsCount - 1
|
||||
)
|
||||
}
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.onFocusChanged { focusState ->
|
||||
isInputFocused = focusState.isFocused
|
||||
if (focusState.isFocused) {
|
||||
coroutineScope.launch {
|
||||
sheetState.expand()
|
||||
// Scroll to bottom to show input and remove button
|
||||
listState.animateScrollToItem(
|
||||
index = listState.layoutInfo.totalItemsCount - 1
|
||||
)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
decorationBox = { innerTextField ->
|
||||
if (customGeohash.isEmpty()) {
|
||||
Text(
|
||||
text = "geohash",
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
)
|
||||
|
||||
val normalized = customGeohash.trim().lowercase().replace("#", "")
|
||||
|
||||
// Map picker button
|
||||
IconButton(onClick = {
|
||||
val initial = when {
|
||||
normalized.isNotBlank() -> normalized
|
||||
selectedChannel is ChannelID.Location -> (selectedChannel as ChannelID.Location).channel.geohash
|
||||
else -> ""
|
||||
}
|
||||
val intent = Intent(context, GeohashPickerActivity::class.java).apply {
|
||||
putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial)
|
||||
}
|
||||
mapPickerLauncher.launch(intent)
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Map,
|
||||
contentDescription = "Open map",
|
||||
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
|
||||
val isValid = validateGeohash(normalized)
|
||||
|
||||
// iOS-style teleport button
|
||||
Button(
|
||||
onClick = {
|
||||
if (isValid) {
|
||||
val level = levelForLength(normalized.length)
|
||||
val channel = GeohashChannel(level = level, geohash = normalized)
|
||||
// Mark this selection as a manual teleport
|
||||
locationManager.setTeleported(true)
|
||||
locationManager.select(ChannelID.Location(channel))
|
||||
onDismiss()
|
||||
} else {
|
||||
customError = "invalid geohash"
|
||||
}
|
||||
},
|
||||
enabled = isValid,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
singleLine = true,
|
||||
decorationBox = { innerTextField ->
|
||||
if (customGeohash.isEmpty()) {
|
||||
Text(
|
||||
text = "teleport",
|
||||
text = "geohash",
|
||||
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",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val normalized = customGeohash.trim().lowercase().replace("#", "")
|
||||
|
||||
customError?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color.Red
|
||||
// Map picker button
|
||||
IconButton(onClick = {
|
||||
val initial = when {
|
||||
normalized.isNotBlank() -> normalized
|
||||
selectedChannel is ChannelID.Location -> (selectedChannel as ChannelID.Location).channel.geohash
|
||||
else -> ""
|
||||
}
|
||||
val intent = Intent(context, GeohashPickerActivity::class.java).apply {
|
||||
putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial)
|
||||
}
|
||||
mapPickerLauncher.launch(intent)
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Map,
|
||||
contentDescription = "Open map",
|
||||
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
|
||||
val isValid = validateGeohash(normalized)
|
||||
|
||||
// iOS-style teleport button
|
||||
Button(
|
||||
onClick = {
|
||||
if (isValid) {
|
||||
val level = levelForLength(normalized.length)
|
||||
val channel = GeohashChannel(level = level, geohash = normalized)
|
||||
// Mark this selection as a manual teleport
|
||||
locationManager.setTeleported(true)
|
||||
locationManager.select(ChannelID.Location(channel))
|
||||
onDismiss()
|
||||
} else {
|
||||
customError = "invalid geohash"
|
||||
}
|
||||
},
|
||||
enabled = isValid,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "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",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Location services toggle button
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
if (locationServicesEnabled) {
|
||||
locationManager.disableLocationServices()
|
||||
} else {
|
||||
locationManager.enableLocationServices()
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (locationServicesEnabled) {
|
||||
Color.Red.copy(alpha = 0.08f)
|
||||
} else {
|
||||
standardGreen.copy(alpha = 0.12f)
|
||||
},
|
||||
contentColor = if (locationServicesEnabled) {
|
||||
Color(0xFFBF1A1A)
|
||||
} else {
|
||||
standardGreen
|
||||
}
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
|
||||
// Error message for custom geohash
|
||||
if (customError != null) {
|
||||
item(key = "geohash_error") {
|
||||
Text(
|
||||
text = if (locationServicesEnabled) {
|
||||
"disable location services"
|
||||
} else {
|
||||
"enable location services"
|
||||
},
|
||||
text = customError!!,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color.Red,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Location services toggle button
|
||||
item(key = "location_toggle") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 8.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (locationServicesEnabled) {
|
||||
locationManager.disableLocationServices()
|
||||
} else {
|
||||
locationManager.enableLocationServices()
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (locationServicesEnabled) {
|
||||
Color.Red.copy(alpha = 0.08f)
|
||||
} else {
|
||||
standardGreen.copy(alpha = 0.12f)
|
||||
},
|
||||
contentColor = if (locationServicesEnabled) {
|
||||
Color(0xFFBF1A1A)
|
||||
} else {
|
||||
standardGreen
|
||||
}
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = if (locationServicesEnabled) {
|
||||
"disable location services"
|
||||
} else {
|
||||
"enable location services"
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TopBar (animated)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Close",
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle management
|
||||
LaunchedEffect(isPresented) {
|
||||
|
||||
// Lifecycle management: when presented, sample both nearby and bookmarked geohashes
|
||||
LaunchedEffect(isPresented, availableChannels, bookmarks) {
|
||||
if (isPresented) {
|
||||
// Refresh channels when opening (only if location services are enabled)
|
||||
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||||
locationManager.refreshChannels()
|
||||
}
|
||||
// Begin periodic refresh while sheet is open (only if location services are enabled)
|
||||
if (locationServicesEnabled) {
|
||||
locationManager.beginLiveRefresh()
|
||||
}
|
||||
|
||||
// Begin multi-channel sampling for counts
|
||||
val geohashes = availableChannels.map { it.geohash }
|
||||
val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
|
||||
viewModel.beginGeohashSampling(geohashes)
|
||||
} else {
|
||||
locationManager.endLiveRefresh()
|
||||
viewModel.endGeohashSampling()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// React to permission changes
|
||||
LaunchedEffect(permissionState) {
|
||||
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||||
locationManager.refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// React to location services enable/disable
|
||||
LaunchedEffect(locationServicesEnabled) {
|
||||
if (locationServicesEnabled && permissionState == LocationChannelManager.PermissionState.AUTHORIZED) {
|
||||
locationManager.refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
// React to available channels changes
|
||||
LaunchedEffect(availableChannels) {
|
||||
val geohashes = availableChannels.map { it.geohash }
|
||||
viewModel.beginGeohashSampling(geohashes)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -490,6 +607,7 @@ private fun ChannelRow(
|
||||
isSelected: Boolean,
|
||||
titleColor: Color? = null,
|
||||
titleBold: Boolean = false,
|
||||
trailingContent: (@Composable (() -> Unit))? = null,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
// iOS-style list row (plain button, no card background)
|
||||
@@ -501,22 +619,24 @@ private fun ChannelRow(
|
||||
Color.Transparent
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
// Split title to handle count part with smaller font (iOS style)
|
||||
val (baseTitle, countSuffix) = splitTitleAndCount(title)
|
||||
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = baseTitle,
|
||||
@@ -525,7 +645,7 @@ private fun ChannelRow(
|
||||
fontWeight = if (titleBold) FontWeight.Bold else FontWeight.Normal,
|
||||
color = titleColor ?: MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
|
||||
countSuffix?.let { count ->
|
||||
Text(
|
||||
text = count,
|
||||
@@ -535,7 +655,7 @@ private fun ChannelRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 12.sp,
|
||||
@@ -543,14 +663,20 @@ private fun ChannelRow(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
Text(
|
||||
text = "✔︎",
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color(0xFF32D74B) // iOS green for checkmark
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = Color(0xFF32D74B), // iOS green for checkmark
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (trailingContent != null) {
|
||||
trailingContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -587,6 +713,11 @@ private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int
|
||||
return "${channel.level.displayName.lowercase()} [$participantCount $noun]"
|
||||
}
|
||||
|
||||
private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String {
|
||||
val noun = if (participantCount == 1) "person" else "people"
|
||||
return "#$geohash [$participantCount $noun]"
|
||||
}
|
||||
|
||||
private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean {
|
||||
return when (selectedChannel) {
|
||||
is ChannelID.Location -> selectedChannel.channel == channel
|
||||
@@ -625,7 +756,7 @@ private fun coverageString(precision: Int): String {
|
||||
10 -> 1.19
|
||||
else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble())
|
||||
}
|
||||
|
||||
|
||||
// Use metric system for simplicity (could be made locale-aware)
|
||||
val km = maxMeters / 1000.0
|
||||
return "~${formatDistance(km)} km"
|
||||
@@ -645,9 +776,5 @@ private fun bluetoothRangeString(): String {
|
||||
}
|
||||
|
||||
private fun formattedNamePrefix(level: GeohashChannelLevel): String {
|
||||
// return when (level) {
|
||||
// GeohashChannelLevel.REGION -> ""
|
||||
// else -> "~"
|
||||
// }
|
||||
return "~"
|
||||
}
|
||||
|
||||
@@ -74,12 +74,14 @@ object PoWMiningTracker {
|
||||
@Composable
|
||||
fun MessageWithMatrixAnimation(
|
||||
message: com.bitchat.android.model.BitchatMessage,
|
||||
messages: List<com.bitchat.android.model.BitchatMessage> = emptyList(),
|
||||
currentUserNickname: String,
|
||||
meshService: com.bitchat.android.mesh.BluetoothMeshService,
|
||||
colorScheme: androidx.compose.material3.ColorScheme,
|
||||
timeFormatter: java.text.SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((com.bitchat.android.model.BitchatMessage) -> Unit)?,
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isAnimating = shouldAnimateMessage(message.id)
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.util.Log
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import java.util.Date
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Handles media file sending operations (voice notes, images, generic files)
|
||||
* Separated from ChatViewModel for better separation of concerns
|
||||
*/
|
||||
class MediaSendingManager(
|
||||
private val state: ChatState,
|
||||
private val messageManager: MessageManager,
|
||||
private val channelManager: ChannelManager,
|
||||
private val meshService: BluetoothMeshService
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "MediaSendingManager"
|
||||
private const val MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB limit
|
||||
}
|
||||
|
||||
// Track in-flight transfer progress: transferId -> messageId and reverse
|
||||
private val transferMessageMap = mutableMapOf<String, String>()
|
||||
private val messageTransferMap = mutableMapOf<String, String>()
|
||||
|
||||
/**
|
||||
* Send a voice note (audio file)
|
||||
*/
|
||||
fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
|
||||
try {
|
||||
val file = java.io.File(filePath)
|
||||
if (!file.exists()) {
|
||||
Log.e(TAG, "❌ File does not exist: $filePath")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
return
|
||||
}
|
||||
|
||||
val filePacket = BitchatFilePacket(
|
||||
fileName = file.name,
|
||||
fileSize = file.length(),
|
||||
mimeType = "audio/mp4",
|
||||
content = file.readBytes()
|
||||
)
|
||||
|
||||
if (toPeerIDOrNull != null) {
|
||||
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Audio)
|
||||
} else {
|
||||
sendPublicFile(channelOrNull, filePacket, filePath, BitchatMessageType.Audio)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send voice note: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an image file
|
||||
*/
|
||||
fun sendImageNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
|
||||
try {
|
||||
Log.d(TAG, "🔄 Starting image send: $filePath")
|
||||
val file = java.io.File(filePath)
|
||||
if (!file.exists()) {
|
||||
Log.e(TAG, "❌ File does not exist: $filePath")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
return
|
||||
}
|
||||
|
||||
val filePacket = BitchatFilePacket(
|
||||
fileName = file.name,
|
||||
fileSize = file.length(),
|
||||
mimeType = "image/jpeg",
|
||||
content = file.readBytes()
|
||||
)
|
||||
|
||||
if (toPeerIDOrNull != null) {
|
||||
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Image)
|
||||
} else {
|
||||
sendPublicFile(channelOrNull, filePacket, filePath, BitchatMessageType.Image)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ CRITICAL: Image send failed completely", e)
|
||||
Log.e(TAG, "❌ Image path: $filePath")
|
||||
Log.e(TAG, "❌ Error details: ${e.message}")
|
||||
Log.e(TAG, "❌ Error type: ${e.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a generic file
|
||||
*/
|
||||
fun sendFileNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
|
||||
try {
|
||||
Log.d(TAG, "🔄 Starting file send: $filePath")
|
||||
val file = java.io.File(filePath)
|
||||
if (!file.exists()) {
|
||||
Log.e(TAG, "❌ File does not exist: $filePath")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
return
|
||||
}
|
||||
|
||||
// Use the real MIME type based on extension; fallback to octet-stream
|
||||
val mimeType = try {
|
||||
com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name)
|
||||
} catch (_: Exception) {
|
||||
"application/octet-stream"
|
||||
}
|
||||
Log.d(TAG, "🏷️ MIME type: $mimeType")
|
||||
|
||||
// Try to preserve the original file name if our copier prefixed it earlier
|
||||
val originalName = run {
|
||||
val name = file.name
|
||||
val base = name.substringBeforeLast('.')
|
||||
val ext = name.substringAfterLast('.', "").let { if (it.isNotBlank()) ".${it}" else "" }
|
||||
val stripped = Regex("^send_\\d+_(.+)$").matchEntire(base)?.groupValues?.getOrNull(1) ?: base
|
||||
stripped + ext
|
||||
}
|
||||
Log.d(TAG, "📝 Original filename: $originalName")
|
||||
|
||||
val filePacket = BitchatFilePacket(
|
||||
fileName = originalName,
|
||||
fileSize = file.length(),
|
||||
mimeType = mimeType,
|
||||
content = file.readBytes()
|
||||
)
|
||||
Log.d(TAG, "📦 Created file packet successfully")
|
||||
|
||||
val messageType = when {
|
||||
mimeType.lowercase().startsWith("image/") -> BitchatMessageType.Image
|
||||
mimeType.lowercase().startsWith("audio/") -> BitchatMessageType.Audio
|
||||
else -> BitchatMessageType.File
|
||||
}
|
||||
|
||||
if (toPeerIDOrNull != null) {
|
||||
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, messageType)
|
||||
} else {
|
||||
sendPublicFile(channelOrNull, filePacket, filePath, messageType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ CRITICAL: File send failed completely", e)
|
||||
Log.e(TAG, "❌ File path: $filePath")
|
||||
Log.e(TAG, "❌ Error details: ${e.message}")
|
||||
Log.e(TAG, "❌ Error type: ${e.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a file privately (encrypted)
|
||||
*/
|
||||
private fun sendPrivateFile(
|
||||
toPeerID: String,
|
||||
filePacket: BitchatFilePacket,
|
||||
filePath: String,
|
||||
messageType: BitchatMessageType
|
||||
) {
|
||||
val payload = filePacket.encode()
|
||||
if (payload == null) {
|
||||
Log.e(TAG, "❌ Failed to encode file packet for private send")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "🔒 Encoded private packet: ${payload.size} bytes")
|
||||
|
||||
val transferId = sha256Hex(payload)
|
||||
val contentHash = sha256Hex(filePacket.content)
|
||||
|
||||
Log.d(TAG, "📤 FILE_TRANSFER send (private): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, to=${toPeerID.take(8)} transferId=${transferId.take(16)}…")
|
||||
|
||||
val msg = BitchatMessage(
|
||||
id = java.util.UUID.randomUUID().toString().uppercase(), // Generate unique ID for each message
|
||||
sender = state.getNicknameValue() ?: "me",
|
||||
content = filePath,
|
||||
type = messageType,
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
isPrivate = true,
|
||||
recipientNickname = try { meshService.getPeerNicknames()[toPeerID] } catch (_: Exception) { null },
|
||||
senderPeerID = meshService.myPeerID
|
||||
)
|
||||
|
||||
messageManager.addPrivateMessage(toPeerID, msg)
|
||||
|
||||
synchronized(transferMessageMap) {
|
||||
transferMessageMap[transferId] = msg.id
|
||||
messageTransferMap[msg.id] = transferId
|
||||
}
|
||||
|
||||
// Seed progress so delivery icons render for media
|
||||
messageManager.updateMessageDeliveryStatus(
|
||||
msg.id,
|
||||
com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(0, 100)
|
||||
)
|
||||
|
||||
Log.d(TAG, "📤 Calling meshService.sendFilePrivate to $toPeerID")
|
||||
meshService.sendFilePrivate(toPeerID, filePacket)
|
||||
Log.d(TAG, "✅ File send completed successfully")
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a file publicly (broadcast or channel)
|
||||
*/
|
||||
private fun sendPublicFile(
|
||||
channelOrNull: String?,
|
||||
filePacket: BitchatFilePacket,
|
||||
filePath: String,
|
||||
messageType: BitchatMessageType
|
||||
) {
|
||||
val payload = filePacket.encode()
|
||||
if (payload == null) {
|
||||
Log.e(TAG, "❌ Failed to encode file packet for broadcast send")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "🔓 Encoded broadcast packet: ${payload.size} bytes")
|
||||
|
||||
val transferId = sha256Hex(payload)
|
||||
val contentHash = sha256Hex(filePacket.content)
|
||||
|
||||
Log.d(TAG, "📤 FILE_TRANSFER send (broadcast): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, transferId=${transferId.take(16)}…")
|
||||
|
||||
val message = BitchatMessage(
|
||||
id = java.util.UUID.randomUUID().toString().uppercase(), // Generate unique ID for each message
|
||||
sender = state.getNicknameValue() ?: meshService.myPeerID,
|
||||
content = filePath,
|
||||
type = messageType,
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
senderPeerID = meshService.myPeerID,
|
||||
channel = channelOrNull
|
||||
)
|
||||
|
||||
if (!channelOrNull.isNullOrBlank()) {
|
||||
channelManager.addChannelMessage(channelOrNull, message, meshService.myPeerID)
|
||||
} else {
|
||||
messageManager.addMessage(message)
|
||||
}
|
||||
|
||||
synchronized(transferMessageMap) {
|
||||
transferMessageMap[transferId] = message.id
|
||||
messageTransferMap[message.id] = transferId
|
||||
}
|
||||
|
||||
// Seed progress so animations start immediately
|
||||
messageManager.updateMessageDeliveryStatus(
|
||||
message.id,
|
||||
com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(0, 100)
|
||||
)
|
||||
|
||||
Log.d(TAG, "📤 Calling meshService.sendFileBroadcast")
|
||||
meshService.sendFileBroadcast(filePacket)
|
||||
Log.d(TAG, "✅ File broadcast completed successfully")
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a media transfer by message ID
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress for a transfer
|
||||
*/
|
||||
fun updateTransferProgress(transferId: String, messageId: String) {
|
||||
synchronized(transferMessageMap) {
|
||||
transferMessageMap[transferId] = messageId
|
||||
messageTransferMap[messageId] = transferId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle transfer progress events
|
||||
*/
|
||||
fun handleTransferProgressEvent(evt: com.bitchat.android.mesh.TransferProgressEvent) {
|
||||
val msgId = synchronized(transferMessageMap) { transferMessageMap[evt.transferId] }
|
||||
if (msgId != null) {
|
||||
if (evt.completed) {
|
||||
messageManager.updateMessageDeliveryStatus(
|
||||
msgId,
|
||||
com.bitchat.android.model.DeliveryStatus.Delivered(to = "mesh", at = java.util.Date())
|
||||
)
|
||||
synchronized(transferMessageMap) {
|
||||
val msgIdRemoved = transferMessageMap.remove(evt.transferId)
|
||||
if (msgIdRemoved != null) messageTransferMap.remove(msgIdRemoved)
|
||||
}
|
||||
} else {
|
||||
messageManager.updateMessageDeliveryStatus(
|
||||
msgId,
|
||||
com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(evt.sent, evt.total)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sha256Hex(bytes: ByteArray): String = try {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
md.update(bytes)
|
||||
md.digest().joinToString("") { "%02x".format(it) }
|
||||
} catch (_: Exception) {
|
||||
bytes.size.toString(16)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.ui.NotificationTextUtils
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
@@ -55,10 +56,11 @@ class MeshDelegateHandler(
|
||||
message.senderPeerID?.let { senderPeerID ->
|
||||
// Use nickname if available, fall back to sender or senderPeerID
|
||||
val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID
|
||||
val preview = NotificationTextUtils.buildPrivateMessagePreview(message)
|
||||
notificationManager.showPrivateMessageNotification(
|
||||
senderPeerID = senderPeerID,
|
||||
senderNickname = senderNickname,
|
||||
messageContent = message.content
|
||||
senderPeerID = senderPeerID,
|
||||
senderNickname = senderNickname,
|
||||
messageContent = preview
|
||||
)
|
||||
}
|
||||
} else if (message.channel != null) {
|
||||
@@ -285,4 +287,5 @@ class MeshDelegateHandler(
|
||||
fun getPeerInfo(peerID: String): com.bitchat.android.mesh.PeerInfo? {
|
||||
return getMeshService().getPeerInfo(peerID)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -15,6 +16,9 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -30,6 +34,17 @@ import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import com.bitchat.android.ui.media.VoiceNotePlayer
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import com.bitchat.android.ui.media.FileMessageItem
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
|
||||
// VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer
|
||||
|
||||
/**
|
||||
* Message display components for ChatScreen
|
||||
@@ -45,7 +60,9 @@ fun MessagesList(
|
||||
forceScrollToBottom: Boolean = false,
|
||||
onScrolledUpChanged: ((Boolean) -> Unit)? = null,
|
||||
onNicknameClick: ((String) -> Unit)? = null,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)? = null
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)? = null
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
@@ -97,13 +114,19 @@ fun MessagesList(
|
||||
modifier = modifier,
|
||||
reverseLayout = true
|
||||
) {
|
||||
items(messages.asReversed()) { message ->
|
||||
items(
|
||||
items = messages.asReversed(),
|
||||
key = { it.id }
|
||||
) { message ->
|
||||
MessageItem(
|
||||
message = message,
|
||||
messages = messages,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -115,8 +138,11 @@ fun MessageItem(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
messages: List<BitchatMessage> = emptyList(),
|
||||
onNicknameClick: ((String) -> Unit)? = null,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)? = null
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)? = null
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
|
||||
@@ -125,27 +151,42 @@ fun MessageItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
// Create a custom layout that combines selectable text with clickable nickname areas
|
||||
MessageTextWithClickableNicknames(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Delivery status for private messages
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
// Provide a small end padding for own private messages so overlay doesn't cover text
|
||||
val endPad = if (message.isPrivate && message.sender == currentUserNickname) 16.dp else 0.dp
|
||||
// Create a custom layout that combines selectable text with clickable nickname areas
|
||||
MessageTextWithClickableNicknames(
|
||||
message = message,
|
||||
messages = messages,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
onImageClick = onImageClick,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = endPad)
|
||||
)
|
||||
}
|
||||
|
||||
// Delivery status for private messages (overlay, non-displacing)
|
||||
if (message.isPrivate && message.sender == currentUserNickname) {
|
||||
message.deliveryStatus?.let { status ->
|
||||
DeliveryStatusIcon(status = status)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
DeliveryStatusIcon(status = status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,16 +197,155 @@ fun MessageItem(
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun MessageTextWithClickableNicknames(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
private fun MessageTextWithClickableNicknames(
|
||||
message: BitchatMessage,
|
||||
messages: List<BitchatMessage>,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// Image special rendering
|
||||
if (message.type == BitchatMessageType.Image) {
|
||||
com.bitchat.android.ui.media.ImageMessageItem(
|
||||
message = message,
|
||||
messages = messages,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
onImageClick = onImageClick,
|
||||
modifier = modifier
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Voice note special rendering
|
||||
if (message.type == BitchatMessageType.Audio) {
|
||||
com.bitchat.android.ui.media.AudioMessageItem(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onCancelTransfer = onCancelTransfer,
|
||||
modifier = modifier
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// File special rendering
|
||||
if (message.type == BitchatMessageType.File) {
|
||||
val path = message.content.trim()
|
||||
// Derive sending progress if applicable
|
||||
val (overrideProgress, _) = when (val st = message.deliveryStatus) {
|
||||
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> {
|
||||
if (st.total > 0 && st.reached < st.total) {
|
||||
(st.reached.toFloat() / st.total.toFloat()) to Color(0xFF1E88E5) // blue while sending
|
||||
} else null to null
|
||||
}
|
||||
else -> null to null
|
||||
}
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Header: nickname + timestamp line above the file, identical styling to text messages
|
||||
val headerText = formatMessageHeaderAnnotatedString(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
Text(
|
||||
text = headerText,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface,
|
||||
modifier = Modifier.pointerInput(message.id) {
|
||||
detectTapGestures(onTap = { pos ->
|
||||
val layout = headerLayout ?: return@detectTapGestures
|
||||
val offset = layout.getOffsetForPosition(pos)
|
||||
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
|
||||
if (ann.isNotEmpty() && onNicknameClick != null) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(ann.first().item)
|
||||
}
|
||||
}, onLongPress = { onMessageLongPress?.invoke(message) })
|
||||
},
|
||||
onTextLayout = { headerLayout = it }
|
||||
)
|
||||
|
||||
// Try to load the file packet from the path
|
||||
val packet = try {
|
||||
val file = java.io.File(path)
|
||||
if (file.exists()) {
|
||||
// Create a temporary BitchatFilePacket for display
|
||||
// In a real implementation, this would be stored with the packet metadata
|
||||
com.bitchat.android.model.BitchatFilePacket(
|
||||
fileName = file.name,
|
||||
fileSize = file.length(),
|
||||
mimeType = com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name),
|
||||
content = file.readBytes()
|
||||
)
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
Box {
|
||||
if (packet != null) {
|
||||
if (overrideProgress != null) {
|
||||
// Show sending animation while in-flight
|
||||
com.bitchat.android.ui.media.FileSendingAnimation(
|
||||
fileName = packet.fileName,
|
||||
progress = overrideProgress,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
// Static file display with open/save dialog
|
||||
FileMessageItem(
|
||||
packet = packet,
|
||||
onFileClick = {
|
||||
// handled inside FileMessageItem via dialog
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Cancel button overlay during sending
|
||||
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is DeliveryStatus.PartiallyDelivered)
|
||||
if (showCancel) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(22.dp)
|
||||
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
|
||||
.clickable { onCancelTransfer?.invoke(message) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.Close, contentDescription = "Cancel", tint = Color.White, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(text = "[file unavailable]", fontFamily = FontFamily.Monospace, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this message should be animated during PoW mining
|
||||
val shouldAnimate = shouldAnimateMessage(message.id)
|
||||
|
||||
@@ -174,12 +354,14 @@ private fun MessageTextWithClickableNicknames(
|
||||
// Display message with matrix animation for content
|
||||
MessageWithMatrixAnimation(
|
||||
message = message,
|
||||
messages = messages,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
onImageClick = onImageClick,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
@@ -326,8 +508,9 @@ fun DeliveryStatusIcon(status: DeliveryStatus) {
|
||||
)
|
||||
}
|
||||
is DeliveryStatus.PartiallyDelivered -> {
|
||||
// Show a single subdued check without numeric label
|
||||
Text(
|
||||
text = "✓${status.reached}/${status.total}",
|
||||
text = "✓",
|
||||
fontSize = 10.sp,
|
||||
color = colorScheme.primary.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
@@ -244,6 +244,49 @@ class MessageManager(private val state: ChatState) {
|
||||
}
|
||||
state.setChannelMessages(updatedChannelMessages)
|
||||
}
|
||||
|
||||
// Remove a message from all locations (main timeline, private chats, channels)
|
||||
fun removeMessageById(messageID: String) {
|
||||
// Main timeline
|
||||
run {
|
||||
val list = state.getMessagesValue().toMutableList()
|
||||
val idx = list.indexOfFirst { it.id == messageID }
|
||||
if (idx >= 0) {
|
||||
list.removeAt(idx)
|
||||
state.setMessages(list)
|
||||
}
|
||||
}
|
||||
// Private chats
|
||||
run {
|
||||
val chats = state.getPrivateChatsValue().toMutableMap()
|
||||
var changed = false
|
||||
chats.keys.toList().forEach { key ->
|
||||
val msgs = chats[key]?.toMutableList() ?: mutableListOf()
|
||||
val idx = msgs.indexOfFirst { it.id == messageID }
|
||||
if (idx >= 0) {
|
||||
msgs.removeAt(idx)
|
||||
chats[key] = msgs
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) state.setPrivateChats(chats)
|
||||
}
|
||||
// Channels
|
||||
run {
|
||||
val chans = state.getChannelMessagesValue().toMutableMap()
|
||||
var changed = false
|
||||
chans.keys.toList().forEach { ch ->
|
||||
val msgs = chans[ch]?.toMutableList() ?: mutableListOf()
|
||||
val idx = msgs.indexOfFirst { it.id == messageID }
|
||||
if (idx >= 0) {
|
||||
msgs.removeAt(idx)
|
||||
chans[ch] = msgs
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) state.setChannelMessages(chans)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Utility Functions
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
|
||||
/**
|
||||
* Utilities for building human-friendly notification text/previews.
|
||||
*/
|
||||
object NotificationTextUtils {
|
||||
/**
|
||||
* Build a user-friendly notification preview for private messages, especially attachments.
|
||||
* Examples:
|
||||
* - Image: "📷 sent an image"
|
||||
* - Audio: "🎤 sent a voice message"
|
||||
* - File (pdf): "📄 file.pdf"
|
||||
* - Text: original message content
|
||||
*/
|
||||
fun buildPrivateMessagePreview(message: BitchatMessage): String {
|
||||
return try {
|
||||
when (message.type) {
|
||||
BitchatMessageType.Image -> "📷 sent an image"
|
||||
BitchatMessageType.Audio -> "🎤 sent a voice message"
|
||||
BitchatMessageType.File -> {
|
||||
// Show just the filename (not the full path)
|
||||
val name = try { java.io.File(message.content).name } catch (_: Exception) { null }
|
||||
if (!name.isNullOrBlank()) {
|
||||
val lower = name.lowercase()
|
||||
val icon = when {
|
||||
lower.endsWith(".pdf") -> "📄"
|
||||
lower.endsWith(".zip") || lower.endsWith(".rar") || lower.endsWith(".7z") -> "🗜️"
|
||||
lower.endsWith(".doc") || lower.endsWith(".docx") -> "📄"
|
||||
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> "📊"
|
||||
lower.endsWith(".ppt") || lower.endsWith(".pptx") -> "📈"
|
||||
else -> "📎"
|
||||
}
|
||||
"$icon $name"
|
||||
} else {
|
||||
"📎 sent a file"
|
||||
}
|
||||
}
|
||||
else -> message.content
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Fallback to original content on any error
|
||||
message.content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,7 @@ fun SidebarOverlay(
|
||||
else -> {
|
||||
// Show mesh peer list when in mesh channel (default)
|
||||
PeopleSection(
|
||||
modifier = modifier.padding(bottom = 16.dp),
|
||||
connectedPeers = connectedPeers,
|
||||
peerNicknames = peerNicknames,
|
||||
peerRSSI = peerRSSI,
|
||||
@@ -248,6 +249,7 @@ fun ChannelsSection(
|
||||
|
||||
@Composable
|
||||
fun PeopleSection(
|
||||
modifier: Modifier = Modifier,
|
||||
connectedPeers: List<String>,
|
||||
peerNicknames: Map<String, String>,
|
||||
peerRSSI: Map<String, Int>,
|
||||
@@ -257,7 +259,7 @@ fun PeopleSection(
|
||||
viewModel: ChatViewModel,
|
||||
onPrivateChatStart: (String) -> Unit
|
||||
) {
|
||||
Column {
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -328,8 +330,6 @@ fun PeopleSection(
|
||||
return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12)))
|
||||
}
|
||||
|
||||
|
||||
|
||||
val baseNameCounts = mutableMapOf<String, Int>()
|
||||
|
||||
// Connected peers
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
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.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.features.voice.VoiceRecorder
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionStatus
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun VoiceRecordButton(
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color,
|
||||
onStart: () -> Unit,
|
||||
onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit,
|
||||
onFinish: (filePath: String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
|
||||
|
||||
var isRecording by remember { mutableStateOf(false) }
|
||||
var recorder by remember { mutableStateOf<VoiceRecorder?>(null) }
|
||||
var recordedFilePath by remember { mutableStateOf<String?>(null) }
|
||||
var recordingStart by remember { mutableStateOf(0L) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var ampJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
// Ensure latest callbacks are used inside gesture coroutine
|
||||
val latestOnStart = rememberUpdatedState(onStart)
|
||||
val latestOnAmplitude = rememberUpdatedState(onAmplitude)
|
||||
val latestOnFinish = rememberUpdatedState(onFinish)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(32.dp)
|
||||
.background(backgroundColor, CircleShape)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
if (!isRecording) {
|
||||
if (micPermission.status !is PermissionStatus.Granted) {
|
||||
micPermission.launchPermissionRequest()
|
||||
return@detectTapGestures
|
||||
}
|
||||
val rec = VoiceRecorder(context)
|
||||
val f = rec.start()
|
||||
recorder = rec
|
||||
isRecording = f != null
|
||||
recordedFilePath = f?.absolutePath
|
||||
recordingStart = System.currentTimeMillis()
|
||||
if (isRecording) {
|
||||
latestOnStart.value()
|
||||
// Haptic "knock" when recording starts
|
||||
try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
|
||||
// Start amplitude polling loop
|
||||
ampJob?.cancel()
|
||||
ampJob = scope.launch {
|
||||
while (isActive && isRecording) {
|
||||
val amp = recorder?.pollAmplitude() ?: 0
|
||||
val elapsedMs = (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L)
|
||||
latestOnAmplitude.value(amp, elapsedMs)
|
||||
// Auto-stop after 10 seconds
|
||||
if (elapsedMs >= 10_000 && isRecording) {
|
||||
val file = recorder?.stop()
|
||||
isRecording = false
|
||||
recorder = null
|
||||
val path = file?.absolutePath
|
||||
if (!path.isNullOrBlank()) {
|
||||
// Haptic "knock" on auto stop
|
||||
try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
|
||||
latestOnFinish.value(path)
|
||||
}
|
||||
break
|
||||
}
|
||||
delay(80)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
awaitRelease()
|
||||
} finally {
|
||||
if (isRecording) {
|
||||
// Extend recording for 500ms after release to avoid clipping
|
||||
delay(500)
|
||||
}
|
||||
if (isRecording) {
|
||||
val file = recorder?.stop()
|
||||
isRecording = false
|
||||
recorder = null
|
||||
val path = (file?.absolutePath ?: recordedFilePath)
|
||||
recordedFilePath = null
|
||||
if (!path.isNullOrBlank()) {
|
||||
// Haptic "knock" when recording stops
|
||||
try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
|
||||
latestOnFinish.value(path)
|
||||
}
|
||||
}
|
||||
ampJob?.cancel()
|
||||
ampJob = null
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Mic,
|
||||
contentDescription = "Record voice note",
|
||||
tint = Color.Black,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ object DebugPreferenceManager {
|
||||
private const val KEY_MAX_CONN_OVERALL = "max_connections_overall"
|
||||
private const val KEY_MAX_CONN_SERVER = "max_connections_server"
|
||||
private const val KEY_MAX_CONN_CLIENT = "max_connections_client"
|
||||
private const val KEY_SEEN_PACKET_CAP = "seen_packet_capacity"
|
||||
// GCS keys (no migration/back-compat)
|
||||
private const val KEY_GCS_MAX_BYTES = "gcs_max_filter_bytes"
|
||||
private const val KEY_GCS_FPR = "gcs_filter_fpr_percent"
|
||||
|
||||
private lateinit var prefs: SharedPreferences
|
||||
|
||||
@@ -74,4 +78,26 @@ object DebugPreferenceManager {
|
||||
fun setMaxConnectionsClient(value: Int) {
|
||||
if (ready()) prefs.edit().putInt(KEY_MAX_CONN_CLIENT, value).apply()
|
||||
}
|
||||
|
||||
// Sync/GCS settings
|
||||
fun getSeenPacketCapacity(default: Int = 500): Int =
|
||||
if (ready()) prefs.getInt(KEY_SEEN_PACKET_CAP, default) else default
|
||||
|
||||
fun setSeenPacketCapacity(value: Int) {
|
||||
if (ready()) prefs.edit().putInt(KEY_SEEN_PACKET_CAP, value).apply()
|
||||
}
|
||||
|
||||
fun getGcsMaxFilterBytes(default: Int = 400): Int =
|
||||
if (ready()) prefs.getInt(KEY_GCS_MAX_BYTES, default) else default
|
||||
|
||||
fun setGcsMaxFilterBytes(value: Int) {
|
||||
if (ready()) prefs.edit().putInt(KEY_GCS_MAX_BYTES, value).apply()
|
||||
}
|
||||
|
||||
fun getGcsFprPercent(default: Double = 1.0): Double =
|
||||
if (ready()) java.lang.Double.longBitsToDouble(prefs.getLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(default))) else default
|
||||
|
||||
fun setGcsFprPercent(value: Double) {
|
||||
if (ready()) prefs.edit().putLong(KEY_GCS_FPR, java.lang.Double.doubleToRawLongBits(value)).apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +201,37 @@ class DebugSettingsManager private constructor() {
|
||||
fun updateRelayStats(stats: PacketRelayStats) {
|
||||
_relayStats.value = stats
|
||||
}
|
||||
|
||||
// Sync/GCS settings (UI-configurable)
|
||||
private val _seenPacketCapacity = MutableStateFlow(DebugPreferenceManager.getSeenPacketCapacity(500))
|
||||
val seenPacketCapacity: StateFlow<Int> = _seenPacketCapacity.asStateFlow()
|
||||
|
||||
private val _gcsMaxBytes = MutableStateFlow(DebugPreferenceManager.getGcsMaxFilterBytes(400))
|
||||
val gcsMaxBytes: StateFlow<Int> = _gcsMaxBytes.asStateFlow()
|
||||
|
||||
private val _gcsFprPercent = MutableStateFlow(DebugPreferenceManager.getGcsFprPercent(1.0))
|
||||
val gcsFprPercent: StateFlow<Double> = _gcsFprPercent.asStateFlow()
|
||||
|
||||
fun setSeenPacketCapacity(value: Int) {
|
||||
val clamped = value.coerceIn(10, 1000)
|
||||
DebugPreferenceManager.setSeenPacketCapacity(clamped)
|
||||
_seenPacketCapacity.value = clamped
|
||||
addDebugMessage(DebugMessage.SystemMessage("🧩 max packets per sync set to $clamped"))
|
||||
}
|
||||
|
||||
fun setGcsMaxBytes(value: Int) {
|
||||
val clamped = value.coerceIn(128, 1024)
|
||||
DebugPreferenceManager.setGcsMaxFilterBytes(clamped)
|
||||
_gcsMaxBytes.value = clamped
|
||||
addDebugMessage(DebugMessage.SystemMessage("🌸 max GCS filter size set to $clamped bytes"))
|
||||
}
|
||||
|
||||
fun setGcsFprPercent(value: Double) {
|
||||
val clamped = value.coerceIn(0.1, 5.0)
|
||||
DebugPreferenceManager.setGcsFprPercent(clamped)
|
||||
_gcsFprPercent.value = clamped
|
||||
addDebugMessage(DebugMessage.SystemMessage("🎯 GCS FPR set to ${String.format("%.2f", clamped)}%"))
|
||||
}
|
||||
|
||||
// MARK: - Debug Message Creation Helpers
|
||||
|
||||
@@ -226,11 +257,10 @@ class DebugSettingsManager private constructor() {
|
||||
val who = if (!senderNickname.isNullOrBlank()) "$senderNickname ($senderPeerID)" else senderPeerID
|
||||
val routeInfo = if (!viaDeviceId.isNullOrBlank()) " via $viaDeviceId" else " (direct)"
|
||||
addDebugMessage(DebugMessage.PacketEvent(
|
||||
"📥 Received $messageType from $who$routeInfo"
|
||||
"📦 Received $messageType from $who$routeInfo"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun logPacketRelay(
|
||||
packetType: String,
|
||||
originalPeerID: String,
|
||||
@@ -248,9 +278,11 @@ class DebugSettingsManager private constructor() {
|
||||
toPeerID = null,
|
||||
toNickname = null,
|
||||
toDeviceAddress = null,
|
||||
ttl = null
|
||||
ttl = null,
|
||||
isRelay = true
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// New, more detailed relay logger used by the mesh/broadcaster
|
||||
fun logPacketRelayDetailed(
|
||||
@@ -263,7 +295,8 @@ class DebugSettingsManager private constructor() {
|
||||
toPeerID: String?,
|
||||
toNickname: String?,
|
||||
toDeviceAddress: String?,
|
||||
ttl: UByte?
|
||||
ttl: UByte?,
|
||||
isRelay: Boolean = true
|
||||
) {
|
||||
// Build message only if verbose logging is enabled, but always update stats
|
||||
val senderLabel = when {
|
||||
@@ -288,16 +321,26 @@ class DebugSettingsManager private constructor() {
|
||||
val ttlStr = ttl?.toString() ?: "?"
|
||||
|
||||
if (verboseLoggingEnabled.value) {
|
||||
addDebugMessage(
|
||||
DebugMessage.RelayEvent(
|
||||
"♻️ Relayed $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr"
|
||||
if (isRelay) {
|
||||
addDebugMessage(
|
||||
DebugMessage.RelayEvent(
|
||||
"♻️ Relayed $packetType by $senderLabel from $fromName (${fromPeerID ?: "?"}, $fromAddr) to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr"
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
addDebugMessage(
|
||||
DebugMessage.PacketEvent(
|
||||
"📤 Sent $packetType by $senderLabel to $toName (${toPeerID ?: "?"}, $toAddr) with TTL $ttlStr"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update rolling statistics
|
||||
relayTimestamps.offer(System.currentTimeMillis())
|
||||
updateRelayStatsFromTimestamps()
|
||||
// Update rolling statistics only for relays
|
||||
if (isRelay) {
|
||||
relayTimestamps.offer(System.currentTimeMillis())
|
||||
updateRelayStatsFromTimestamps()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Clear Data
|
||||
|
||||
@@ -133,6 +133,9 @@ fun DebugSettingsSheet(
|
||||
val scanResults by manager.scanResults.collectAsState()
|
||||
val connectedDevices by manager.connectedDevices.collectAsState()
|
||||
val relayStats by manager.relayStats.collectAsState()
|
||||
val seenCapacity by manager.seenPacketCapacity.collectAsState()
|
||||
val gcsMaxBytes by manager.gcsMaxBytes.collectAsState()
|
||||
val gcsFpr by manager.gcsFprPercent.collectAsState()
|
||||
|
||||
// Push live connected devices from mesh service whenever sheet is visible
|
||||
LaunchedEffect(isPresented) {
|
||||
@@ -417,6 +420,27 @@ fun MeshTopologySection() {
|
||||
|
||||
|
||||
|
||||
// Connected devices
|
||||
item {
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
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(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))
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connected devices
|
||||
item {
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.bitchat.android.ui.events
|
||||
|
||||
/**
|
||||
* Lightweight dispatcher so lower-level UI (MessageInput) can trigger
|
||||
* file sending without holding a direct reference to ChatViewModel.
|
||||
*/
|
||||
object FileShareDispatcher {
|
||||
@Volatile private var handler: ((String?, String?, String) -> Unit)? = null
|
||||
|
||||
fun setHandler(h: ((String?, String?, String) -> Unit)?) {
|
||||
handler = h
|
||||
}
|
||||
|
||||
fun dispatch(peerIdOrNull: String?, channelOrNull: String?, path: String) {
|
||||
handler?.invoke(peerIdOrNull, channelOrNull, path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
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 com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
@Composable
|
||||
fun AudioMessageItem(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val path = message.content.trim()
|
||||
// Derive sending progress if applicable
|
||||
val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) {
|
||||
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> {
|
||||
if (st.total > 0 && st.reached < st.total) {
|
||||
(st.reached.toFloat() / st.total.toFloat()) to Color(0xFF1E88E5) // blue while sending
|
||||
} else null to null
|
||||
}
|
||||
else -> null to null
|
||||
}
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Header: nickname + timestamp line above the audio note, identical styling to text messages
|
||||
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
Text(
|
||||
text = headerText,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface,
|
||||
modifier = Modifier.pointerInput(message.id) {
|
||||
detectTapGestures(onTap = { pos ->
|
||||
val layout = headerLayout ?: return@detectTapGestures
|
||||
val offset = layout.getOffsetForPosition(pos)
|
||||
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
|
||||
if (ann.isNotEmpty() && onNicknameClick != null) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(ann.first().item)
|
||||
}
|
||||
}, onLongPress = { onMessageLongPress?.invoke(message) })
|
||||
},
|
||||
onTextLayout = { headerLayout = it }
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
VoiceNotePlayer(
|
||||
path = path,
|
||||
progressOverride = overrideProgress,
|
||||
progressColor = overrideColor
|
||||
)
|
||||
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
|
||||
if (showCancel) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(26.dp)
|
||||
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
|
||||
.clickable { onCancelTransfer?.invoke(message) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.Close, contentDescription = "Cancel", tint = Color.White, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
|
||||
/**
|
||||
* Draws an image progressively, revealing it block-by-block based on progress [0f..1f].
|
||||
* blocksX * blocksY defines the grid density; higher numbers look more "modem-era".
|
||||
*/
|
||||
@Composable
|
||||
fun BlockRevealImage(
|
||||
bitmap: ImageBitmap,
|
||||
progress: Float,
|
||||
blocksX: Int = 24,
|
||||
blocksY: Int = 16,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val frac = progress.coerceIn(0f, 1f)
|
||||
Canvas(modifier = modifier.fillMaxWidth()) {
|
||||
drawProgressive(bitmap, frac, blocksX, blocksY)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawProgressive(
|
||||
bitmap: ImageBitmap,
|
||||
progress: Float,
|
||||
blocksX: Int,
|
||||
blocksY: Int
|
||||
) {
|
||||
val canvasW = size.width
|
||||
val canvasH = size.height
|
||||
if (canvasW <= 0f || canvasH <= 0f) return
|
||||
|
||||
val totalBlocks = (blocksX * blocksY).coerceAtLeast(1)
|
||||
val toShow = (totalBlocks * progress).toInt().coerceIn(0, totalBlocks)
|
||||
if (toShow <= 0) return
|
||||
|
||||
val imgW = bitmap.width
|
||||
val imgH = bitmap.height
|
||||
if (imgW <= 0 || imgH <= 0) return
|
||||
|
||||
// Compute scaled destination rect maintaining aspect fit
|
||||
val canvasRatio = canvasW / canvasH
|
||||
val imageRatio = imgW.toFloat() / imgH.toFloat()
|
||||
val dstW: Float
|
||||
val dstH: Float
|
||||
if (imageRatio >= canvasRatio) {
|
||||
dstW = canvasW
|
||||
dstH = canvasW / imageRatio
|
||||
} else {
|
||||
dstH = canvasH
|
||||
dstW = canvasH * imageRatio
|
||||
}
|
||||
val left = 0f
|
||||
val top = (canvasH - dstH) / 2f
|
||||
|
||||
// Precompute integer edges to avoid 1px gaps due to rounding
|
||||
val xDstEdges = IntArray(blocksX + 1) { i -> (left + (dstW * i / blocksX)).toInt().coerceAtLeast(0) }
|
||||
val yDstEdges = IntArray(blocksY + 1) { i -> (top + (dstH * i / blocksY)).toInt().coerceAtLeast(0) }
|
||||
val xSrcEdges = IntArray(blocksX + 1) { i -> (imgW * i / blocksX) }
|
||||
val ySrcEdges = IntArray(blocksY + 1) { i -> (imgH * i / blocksY) }
|
||||
|
||||
var shown = 0
|
||||
outer@ for (by in 0 until blocksY) {
|
||||
for (bx in 0 until blocksX) {
|
||||
if (shown >= toShow) break@outer
|
||||
val sx = xSrcEdges[bx]
|
||||
val sy = ySrcEdges[by]
|
||||
val sw = xSrcEdges[bx + 1] - xSrcEdges[bx]
|
||||
val sh = ySrcEdges[by + 1] - ySrcEdges[by]
|
||||
val dx = xDstEdges[bx]
|
||||
val dy = yDstEdges[by]
|
||||
val dw = xDstEdges[bx + 1] - xDstEdges[bx]
|
||||
val dh = yDstEdges[by + 1] - yDstEdges[by]
|
||||
|
||||
drawImage(
|
||||
image = bitmap,
|
||||
srcOffset = IntOffset(sx, sy),
|
||||
srcSize = IntSize(sw, sh),
|
||||
dstOffset = IntOffset(dx, dy),
|
||||
dstSize = IntSize(dw.coerceAtLeast(1), dh.coerceAtLeast(1)),
|
||||
alpha = 1f
|
||||
)
|
||||
shown++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.unit.dp
|
||||
import com.bitchat.android.features.file.FileUtils
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
|
||||
/**
|
||||
* Modern chat-style file message display
|
||||
*/
|
||||
@Composable
|
||||
fun FileMessageItem(
|
||||
packet: BitchatFilePacket,
|
||||
onFileClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(0.8f)
|
||||
.clickable { showDialog = true },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// File icon
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Description,
|
||||
contentDescription = "File",
|
||||
tint = getFileIconColor(packet.fileName),
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
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
|
||||
)
|
||||
|
||||
// File details
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = FileUtils.formatFileSize(packet.fileSize),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
// File type indicator
|
||||
FileTypeBadge(mimeType = packet.mimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File viewer dialog
|
||||
if (showDialog) {
|
||||
FileViewerDialog(
|
||||
packet = packet,
|
||||
onDismiss = { showDialog = false },
|
||||
onSaveToDevice = { content, fileName ->
|
||||
// In a real implementation, this would save to Downloads
|
||||
// For now, just log that file was "saved"
|
||||
android.util.Log.d("FileSharing", "Would save file: $fileName")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small badge showing file type
|
||||
*/
|
||||
@Composable
|
||||
private fun FileTypeBadge(mimeType: String) {
|
||||
val (text, color) = when {
|
||||
mimeType.startsWith("application/pdf") -> "PDF" to Color(0xFFDC2626)
|
||||
mimeType.startsWith("text/") -> "TXT" to Color(0xFF059669)
|
||||
mimeType.startsWith("image/") -> "IMG" to Color(0xFF7C3AED)
|
||||
mimeType.startsWith("audio/") -> "AUD" to Color(0xFFEA580C)
|
||||
mimeType.startsWith("video/") -> "VID" to Color(0xFF2563EB)
|
||||
mimeType.contains("document") -> "DOC" to Color(0xFF1D4ED8)
|
||||
mimeType.contains("zip") || mimeType.contains("rar") -> "ZIP" to Color(0xFF7C2D12)
|
||||
else -> "FILE" to MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate icon color based on file extension
|
||||
*/
|
||||
private fun getFileIconColor(fileName: String): Color {
|
||||
val extension = fileName.substringAfterLast(".", "").lowercase()
|
||||
return when (extension) {
|
||||
"pdf" -> Color(0xFFDC2626) // Red
|
||||
"doc", "docx" -> Color(0xFF1D4ED8) // Blue
|
||||
"xls", "xlsx" -> Color(0xFF059669) // Green
|
||||
"ppt", "pptx" -> Color(0xFFEA580C) // Orange
|
||||
"txt", "json", "xml" -> Color(0xFF7C3AED) // Purple
|
||||
"jpg", "png", "gif", "webp" -> Color(0xFF2563EB) // Blue
|
||||
"mp3", "wav", "m4a" -> Color(0xFFEA580C) // Orange
|
||||
"mp4", "avi", "mov" -> Color(0xFFDC2626) // Red
|
||||
"zip", "rar", "7z" -> Color(0xFF7C2D12) // Brown
|
||||
else -> Color(0xFF6B7280) // Gray
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Attachment
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
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 com.bitchat.android.features.file.FileUtils
|
||||
|
||||
@Composable
|
||||
fun FilePickerButton(
|
||||
modifier: Modifier = Modifier,
|
||||
onFileReady: (String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Use SAF - supports all file types
|
||||
val filePicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
// Persist temporary read permission so we can copy
|
||||
try { context.contentResolver.takePersistableUriPermission(uri, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (_: Exception) {}
|
||||
val path = FileUtils.copyFileForSending(context, uri)
|
||||
if (!path.isNullOrBlank()) onFileReady(path)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
// Allow any MIME type; user asked to choose between image or file at higher level UI
|
||||
filePicker.launch(arrayOf("*/*"))
|
||||
},
|
||||
modifier = modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Attachment,
|
||||
contentDescription = "Pick file",
|
||||
tint = Color.Gray,
|
||||
modifier = Modifier.size(20.dp).rotate(90f)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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 kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Matrix-style file sending animation with character-by-character reveal
|
||||
* Shows a file icon with filename being "typed" out character by character
|
||||
* and progress visualization
|
||||
*/
|
||||
@Composable
|
||||
fun FileSendingAnimation(
|
||||
modifier: Modifier = Modifier,
|
||||
fileName: String,
|
||||
progress: Float = 0f
|
||||
) {
|
||||
var revealedChars by remember(fileName) { mutableFloatStateOf(0f) }
|
||||
var showCursor by remember { mutableStateOf(true) }
|
||||
|
||||
// Animate character reveal
|
||||
val animatedChars by animateFloatAsState(
|
||||
targetValue = revealedChars,
|
||||
animationSpec = tween(
|
||||
durationMillis = 50 * fileName.length,
|
||||
easing = LinearEasing
|
||||
),
|
||||
label = "fileNameReveal"
|
||||
)
|
||||
|
||||
// Cursor blinking
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(500)
|
||||
showCursor = !showCursor
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger reveal animation
|
||||
LaunchedEffect(fileName) {
|
||||
revealedChars = fileName.length.toFloat()
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// File icon
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Description,
|
||||
contentDescription = "File",
|
||||
tint = Color(0xFF00C851), // Green like app theme
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Filename reveal animation (Matrix-style)
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
// Revealed part of filename
|
||||
val revealedText = fileName.substring(0, animatedChars.toInt())
|
||||
androidx.compose.material3.Text(
|
||||
text = revealedText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = Color.White
|
||||
),
|
||||
modifier = Modifier.padding(end = 2.dp)
|
||||
)
|
||||
|
||||
// Blinking cursor (only if not fully revealed)
|
||||
if (animatedChars < fileName.length && showCursor) {
|
||||
androidx.compose.material3.Text(
|
||||
text = "_",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Progress visualization
|
||||
FileProgressBars(
|
||||
progress = progress,
|
||||
modifier = Modifier.fillMaxWidth().height(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ASCII-style progress bars for file transfer
|
||||
*/
|
||||
@Composable
|
||||
private fun FileProgressBars(
|
||||
progress: Float,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val bars = 12
|
||||
val filledBars = (progress * bars).toInt()
|
||||
|
||||
// Create a matrix-style progress bar string
|
||||
val progressString = buildString {
|
||||
append("[")
|
||||
for (i in 0 until bars) {
|
||||
append(if (i < filledBars) "█" else "░")
|
||||
}
|
||||
append("] ")
|
||||
append("${(progress * 100).toInt()}%")
|
||||
}
|
||||
|
||||
androidx.compose.material3.Text(
|
||||
text = progressString,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = Color(0xFF00FF7F) // Matrix green
|
||||
),
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.bitchat.android.features.file.FileUtils
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Dialog for handling received file messages in modern chat style
|
||||
*/
|
||||
@Composable
|
||||
fun FileViewerDialog(
|
||||
packet: BitchatFilePacket,
|
||||
onDismiss: () -> Unit,
|
||||
onSaveToDevice: (ByteArray, String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
androidx.compose.material3.Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// File received header
|
||||
Text(
|
||||
text = "📎 File Received",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
// File info
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = "📄 ${packet.fileName}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = "📏 Size: ${FileUtils.formatFileSize(packet.fileSize)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "🏷️ Type: ${packet.mimeType}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Open/Save button
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
// Try to save to Downloads first
|
||||
try {
|
||||
onSaveToDevice(packet.content, packet.fileName)
|
||||
onDismiss()
|
||||
} catch (e: Exception) {
|
||||
// If save fails, try to open directly
|
||||
tryOpenFile(context, packet)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("📂 Open / Save")
|
||||
}
|
||||
|
||||
// Dismiss button
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
) {
|
||||
Text("❌ Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to open a file using system viewers or save to device
|
||||
*/
|
||||
private fun tryOpenFile(context: Context, packet: BitchatFilePacket) {
|
||||
try {
|
||||
// First try to save to temp file and open
|
||||
val tempFile = File.createTempFile("bitchat_", ".${packet.fileName.substringAfterLast(".")}", context.cacheDir)
|
||||
tempFile.writeBytes(packet.content)
|
||||
tempFile.deleteOnExit()
|
||||
|
||||
val uri = androidx.core.content.FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
tempFile
|
||||
)
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, packet.mimeType)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// No app can handle this file type - just show a message
|
||||
// In a real app, you'd show a toast or snackbar
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Handle any errors gracefully
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Fullscreen image viewer with swipe navigation between multiple images
|
||||
* @param imagePaths List of all image file paths in the current chat
|
||||
* @param initialIndex Starting index of the current image in the list
|
||||
* @param onClose Callback when the viewer should be dismissed
|
||||
*/
|
||||
// Backward compatibility for single image (can be removed after updating all callers)
|
||||
@Composable
|
||||
fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
|
||||
FullScreenImageViewer(listOf(path), 0, onClose)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen image viewer with swipe navigation between multiple images
|
||||
* @param imagePaths List of all image file paths in the current chat
|
||||
* @param initialIndex Starting index of the current image in the list
|
||||
* @param onClose Callback when the viewer should be dismissed
|
||||
*/
|
||||
@Composable
|
||||
fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClose: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = imagePaths::size)
|
||||
|
||||
if (imagePaths.isEmpty()) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
Surface(color = Color.Black) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) { page ->
|
||||
val currentPath = imagePaths[page]
|
||||
val bmp = remember(currentPath) { try { android.graphics.BitmapFactory.decodeFile(currentPath) } catch (_: Exception) { null } }
|
||||
|
||||
bmp?.let {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "Image ${page + 1} of ${imagePaths.size}",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
} ?: run {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = "Image unavailable", color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Image counter
|
||||
if (imagePaths.size > 1) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.align(Alignment.TopCenter)
|
||||
.background(Color(0x66000000), androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${(pagerState.currentPage ?: 0) + 1} / ${imagePaths.size}",
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.background(Color(0x66000000), CircleShape)
|
||||
.clickable { saveToDownloads(context, imagePaths[pagerState.currentPage].toString()) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
androidx.compose.material3.Icon(Icons.Filled.Download, "Save current image", tint = Color.White)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.background(Color(0x66000000), CircleShape)
|
||||
.clickable { onClose() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
androidx.compose.material3.Icon(Icons.Filled.Close, "Close", tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToDownloads(context: android.content.Context, path: String) {
|
||||
runCatching {
|
||||
val name = File(path).name
|
||||
val mime = when {
|
||||
name.endsWith(".png", true) -> "image/png"
|
||||
name.endsWith(".webp", true) -> "image/webp"
|
||||
else -> "image/jpeg"
|
||||
}
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Downloads.DISPLAY_NAME, name)
|
||||
put(MediaStore.Downloads.MIME_TYPE, mime)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
put(MediaStore.Downloads.IS_PENDING, 1)
|
||||
}
|
||||
}
|
||||
val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||||
if (uri != null) {
|
||||
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||
File(path).inputStream().use { it.copyTo(out) }
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val v2 = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }
|
||||
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()
|
||||
}
|
||||
}.onFailure {
|
||||
// Optionally handle failure case (e.g., show error toast)
|
||||
Toast.makeText(context, "Failed to save image", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
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.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun ImageMessageItem(
|
||||
message: BitchatMessage,
|
||||
messages: List<BitchatMessage>,
|
||||
currentUserNickname: String,
|
||||
meshService: BluetoothMeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val path = message.content.trim()
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter
|
||||
)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
Text(
|
||||
text = headerText,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface,
|
||||
modifier = Modifier.pointerInput(message.id) {
|
||||
detectTapGestures(onTap = { pos ->
|
||||
val layout = headerLayout ?: return@detectTapGestures
|
||||
val offset = layout.getOffsetForPosition(pos)
|
||||
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
|
||||
if (ann.isNotEmpty() && onNicknameClick != null) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(ann.first().item)
|
||||
}
|
||||
}, onLongPress = { onMessageLongPress?.invoke(message) })
|
||||
},
|
||||
onTextLayout = { headerLayout = it }
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
val bmp = remember(path) { try { android.graphics.BitmapFactory.decodeFile(path) } catch (_: Exception) { null } }
|
||||
|
||||
// Collect all image paths from messages for swipe navigation
|
||||
val imagePaths = remember(messages) {
|
||||
messages.filter { it.type == BitchatMessageType.Image }
|
||||
.map { it.content.trim() }
|
||||
}
|
||||
|
||||
if (bmp != null) {
|
||||
val img = bmp.asImageBitmap()
|
||||
val aspect = (bmp.width.toFloat() / bmp.height.toFloat()).takeIf { it.isFinite() && it > 0 } ?: 1f
|
||||
val progressFraction: Float? = when (val st = message.deliveryStatus) {
|
||||
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> if (st.total > 0) st.reached.toFloat() / st.total.toFloat() else 0f
|
||||
else -> null
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||
Box {
|
||||
if (progressFraction != null && progressFraction < 1f && message.sender == currentUserNickname) {
|
||||
// Cyberpunk block-reveal while sending
|
||||
BlockRevealImage(
|
||||
bitmap = img,
|
||||
progress = progressFraction,
|
||||
blocksX = 24,
|
||||
blocksY = 16,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.aspectRatio(aspect)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp))
|
||||
.clickable {
|
||||
val currentIndex = imagePaths.indexOf(path)
|
||||
onImageClick?.invoke(path, imagePaths, currentIndex)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Fully revealed image
|
||||
Image(
|
||||
bitmap = img,
|
||||
contentDescription = "Image",
|
||||
modifier = Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.aspectRatio(aspect)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp))
|
||||
.clickable {
|
||||
val currentIndex = imagePaths.indexOf(path)
|
||||
onImageClick?.invoke(path, imagePaths, currentIndex)
|
||||
},
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
// Cancel button overlay during sending
|
||||
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered)
|
||||
if (showCancel) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(22.dp)
|
||||
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
|
||||
.clickable { onCancelTransfer?.invoke(message) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.Close, contentDescription = "Cancel", tint = Color.White, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(text = "[image unavailable]", fontFamily = FontFamily.Monospace, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Photo
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.features.media.ImageUtils
|
||||
|
||||
@Composable
|
||||
fun ImagePickerButton(
|
||||
modifier: Modifier = Modifier,
|
||||
onImageReady: (String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val imagePicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri: android.net.Uri? ->
|
||||
if (uri != null) {
|
||||
val outPath = ImageUtils.downscaleAndSaveToAppFiles(context, uri)
|
||||
if (!outPath.isNullOrBlank()) onImageReady(outPath)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { imagePicker.launch("image/*") },
|
||||
modifier = modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Photo,
|
||||
contentDescription = "Pick image",
|
||||
tint = Color.Gray,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
/**
|
||||
* Media picker that offers image and file options
|
||||
* Clicking opens a quick selection menu
|
||||
*/
|
||||
@Composable
|
||||
fun MediaPickerOptions(
|
||||
modifier: Modifier = Modifier,
|
||||
onImagePick: (() -> Unit)? = null,
|
||||
onFilePick: (() -> Unit)? = null
|
||||
) {
|
||||
var showOptions by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
// Main button
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(color = Color.Gray.copy(alpha = 0.5f))
|
||||
.clickable {
|
||||
showOptions = true
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
contentDescription = "Pick media",
|
||||
tint = Color.Black,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Options menu (shown when clicked)
|
||||
if (showOptions) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
translationY = -120f // Position above the button
|
||||
scaleX = 0.8f
|
||||
scaleY = 0.8f
|
||||
}
|
||||
.zIndex(1f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.clickable {
|
||||
showOptions = false
|
||||
}
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
// Image option
|
||||
onImagePick?.let { imagePick ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(color = MaterialTheme.colorScheme.primaryContainer)
|
||||
.clickable {
|
||||
showOptions = false
|
||||
imagePick()
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
androidx.compose.material3.Text(
|
||||
text = "Image",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// File option
|
||||
onFilePick?.let { filePick ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(color = MaterialTheme.colorScheme.secondaryContainer)
|
||||
.clickable {
|
||||
showOptions = false
|
||||
filePick()
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Description,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
androidx.compose.material3.Text(
|
||||
text = "File",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clickable overlay to dismiss options
|
||||
if (showOptions) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(400.dp)
|
||||
.clickable {
|
||||
showOptions = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Real-time scrolling waveform for recording: maintains a dense sliding window of bars.
|
||||
* Pass in normalized amplitude [0f..1f]; the component handles sampling and drawing.
|
||||
*/
|
||||
@Composable
|
||||
fun RealtimeScrollingWaveform(
|
||||
modifier: Modifier = Modifier,
|
||||
amplitudeNorm: Float,
|
||||
bars: Int = 240,
|
||||
barColor: Color = Color(0xFF00FF7F),
|
||||
baseColor: Color = Color(0xFF444444)
|
||||
) {
|
||||
val latestAmp by rememberUpdatedState(amplitudeNorm)
|
||||
val samples: SnapshotStateList<Float> = remember {
|
||||
mutableStateListOf<Float>().also { list -> repeat(bars) { list.add(0f) } }
|
||||
}
|
||||
|
||||
// Append samples on a steady cadence to create a smooth scroll
|
||||
LaunchedEffect(bars) {
|
||||
while (true) {
|
||||
withFrameNanos { _: Long -> }
|
||||
val v = latestAmp.coerceIn(0f, 1f)
|
||||
samples.add(v)
|
||||
val overflow = samples.size - bars
|
||||
if (overflow > 0) repeat(overflow) { if (samples.isNotEmpty()) samples.removeAt(0) }
|
||||
kotlinx.coroutines.delay(20)
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier.fillMaxWidth()) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
if (w <= 0f || h <= 0f) return@Canvas
|
||||
val n = samples.size
|
||||
if (n <= 0) return@Canvas
|
||||
val stepX = w / n
|
||||
val midY = h / 2f
|
||||
val stroke = .5f.dp.toPx()
|
||||
|
||||
// Optional faint base to match chat density
|
||||
// Draw bars with heavy dynamic range compression: quiet sounds almost at zero, loud sounds still prominent
|
||||
for (i in 0 until n) {
|
||||
val amp = samples[i].coerceIn(0f, 1f)
|
||||
// Use squared amplitude to heavily compress small values while preserving high amplitudes
|
||||
// This makes quiet sounds almost invisible but loud sounds still show prominently
|
||||
val compressedAmp = amp * amp // amp^2
|
||||
val lineH = (compressedAmp * (h * 0.9f)).coerceAtLeast(1f)
|
||||
val x = i * stepX + stepX / 2f
|
||||
val yTop = midY - lineH / 2f
|
||||
val yBot = midY + lineH / 2f
|
||||
drawLine(
|
||||
color = barColor,
|
||||
start = Offset(x, yTop),
|
||||
end = Offset(x, yBot),
|
||||
strokeWidth = stroke,
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import android.media.MediaPlayer
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
|
||||
@Composable
|
||||
fun VoiceNotePlayer(
|
||||
path: String,
|
||||
progressOverride: Float? = null,
|
||||
progressColor: Color? = null
|
||||
) {
|
||||
var isPlaying by remember { mutableStateOf(false) }
|
||||
var isPrepared by remember { mutableStateOf(false) }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
var progress by remember { mutableStateOf(0f) }
|
||||
var durationMs by remember { mutableStateOf(0) }
|
||||
val player = remember { MediaPlayer() }
|
||||
|
||||
// Seek function - position is a fraction from 0.0 to 1.0
|
||||
val seekTo: (Float) -> Unit = { position ->
|
||||
if (isPrepared && durationMs > 0) {
|
||||
val seekMs = (position * durationMs).toInt().coerceIn(0, durationMs)
|
||||
try {
|
||||
player.seekTo(seekMs)
|
||||
progress = position // Update progress immediately for UI responsiveness
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(path) {
|
||||
isPrepared = false
|
||||
isError = false
|
||||
progress = 0f
|
||||
durationMs = 0
|
||||
isPlaying = false
|
||||
try {
|
||||
player.reset()
|
||||
player.setOnPreparedListener {
|
||||
isPrepared = true
|
||||
durationMs = try { player.duration } catch (_: Exception) { 0 }
|
||||
}
|
||||
player.setOnCompletionListener {
|
||||
isPlaying = false
|
||||
progress = 1f
|
||||
}
|
||||
player.setOnErrorListener { _, _, _ ->
|
||||
isError = true
|
||||
isPlaying = false
|
||||
true
|
||||
}
|
||||
player.setDataSource(path)
|
||||
player.prepareAsync()
|
||||
} catch (_: Exception) {
|
||||
isError = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isPlaying, isPrepared) {
|
||||
try {
|
||||
if (isPlaying && isPrepared) player.start() else if (isPrepared && player.isPlaying) player.pause()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
LaunchedEffect(isPlaying, isPrepared) {
|
||||
while (isPlaying && isPrepared) {
|
||||
progress = try { player.currentPosition.toFloat() / (player.duration.toFloat().coerceAtLeast(1f)) } catch (_: Exception) { 0f }
|
||||
kotlinx.coroutines.delay(100)
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) { onDispose { try { player.release() } catch (_: Exception) {} } }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Disable play/pause while showing send progress override (optional UX choice)
|
||||
val controlsEnabled = isPrepared && !isError && progressOverride == null
|
||||
FilledTonalIconButton(onClick = { if (controlsEnabled) isPlaying = !isPlaying }, enabled = controlsEnabled, modifier = Modifier.size(28.dp)) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play"
|
||||
)
|
||||
}
|
||||
val progressBarColor = progressColor ?: MaterialTheme.colorScheme.primary
|
||||
com.bitchat.android.ui.media.WaveformPreview(
|
||||
modifier = Modifier
|
||||
.height(24.dp)
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
path = path,
|
||||
sendProgress = progressOverride,
|
||||
playbackProgress = if (progressOverride == null) progress else null,
|
||||
onSeek = seekTo
|
||||
)
|
||||
val durText = if (durationMs > 0) String.format("%02d:%02d", (durationMs / 1000) / 60, (durationMs / 1000) % 60) else "--:--"
|
||||
Text(text = durText, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.bitchat.android.ui.media
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.features.voice.AudioWaveformExtractor
|
||||
import com.bitchat.android.features.voice.VoiceWaveformCache
|
||||
import com.bitchat.android.features.voice.resampleWave
|
||||
|
||||
@Composable
|
||||
fun ScrollingWaveformRecorder(
|
||||
modifier: Modifier = Modifier,
|
||||
currentAmplitude: Float,
|
||||
samples: SnapshotStateList<Float>,
|
||||
maxSamples: Int = 120
|
||||
) {
|
||||
// Append samples at a fixed cadence while visible
|
||||
val latestAmp by rememberUpdatedState(currentAmplitude)
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
withFrameNanos { _: Long -> }
|
||||
val v = latestAmp.coerceIn(0f, 1f)
|
||||
samples.add(v)
|
||||
val overflow = samples.size - maxSamples
|
||||
if (overflow > 0) repeat(overflow) { if (samples.isNotEmpty()) samples.removeAt(0) }
|
||||
kotlinx.coroutines.delay(80)
|
||||
}
|
||||
}
|
||||
WaveformCanvas(modifier = modifier, samples = samples, fillProgress = 1f, baseColor = Color(0xFF444444), fillColor = Color(0xFF00FF7F))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WaveformPreview(
|
||||
modifier: Modifier = Modifier,
|
||||
path: String,
|
||||
sendProgress: Float?,
|
||||
playbackProgress: Float?,
|
||||
onLoaded: ((FloatArray) -> Unit)? = null,
|
||||
onSeek: ((Float) -> Unit)? = null
|
||||
) {
|
||||
val cached = remember(path) { VoiceWaveformCache.get(path) }
|
||||
val stateSamples = remember { mutableStateListOf<Float>() }
|
||||
val progress = (sendProgress ?: playbackProgress)?.coerceIn(0f, 1f) ?: 0f
|
||||
LaunchedEffect(cached) {
|
||||
if (cached != null) {
|
||||
val normalized = if (cached.size != 120) resampleWave(cached, 120) else cached
|
||||
stateSamples.clear(); stateSamples.addAll(normalized.toList())
|
||||
} else {
|
||||
AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr ->
|
||||
if (arr != null) {
|
||||
VoiceWaveformCache.put(path, arr)
|
||||
stateSamples.clear(); stateSamples.addAll(arr.toList())
|
||||
onLoaded?.invoke(arr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
WaveformCanvas(
|
||||
modifier = modifier,
|
||||
samples = stateSamples,
|
||||
fillProgress = if (stateSamples.isEmpty()) 0f else progress,
|
||||
baseColor = Color(0x2200FF7F),
|
||||
fillColor = when {
|
||||
sendProgress != null -> Color(0xFF1E88E5) // blue while sending
|
||||
else -> Color(0xFF00C851) // green during playback
|
||||
},
|
||||
onSeek = onSeek
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WaveformCanvas(
|
||||
modifier: Modifier,
|
||||
samples: List<Float>,
|
||||
fillProgress: Float,
|
||||
baseColor: Color,
|
||||
fillColor: Color,
|
||||
onSeek: ((Float) -> Unit)? = null
|
||||
) {
|
||||
val seekModifier = if (onSeek != null) {
|
||||
modifier.pointerInput(onSeek) {
|
||||
detectTapGestures { offset ->
|
||||
// Calculate the seek position as a fraction (0.0 to 1.0)
|
||||
val position = offset.x / size.width.toFloat()
|
||||
val clampedPosition = position.coerceIn(0f, 1f)
|
||||
onSeek(clampedPosition)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
modifier
|
||||
}
|
||||
|
||||
Canvas(modifier = seekModifier.fillMaxWidth()) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
if (w <= 0f || h <= 0f) return@Canvas
|
||||
val n = samples.size
|
||||
if (n <= 0) return@Canvas
|
||||
val stepX = w / n
|
||||
val midY = h / 2f
|
||||
val radius = 2.dp.toPx()
|
||||
val stroke = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round)
|
||||
val filledUntil = (n * fillProgress).toInt()
|
||||
for (i in 0 until n) {
|
||||
val amp = samples[i].coerceIn(0f, 1f)
|
||||
val lineH = (amp * (h * 0.8f)).coerceAtLeast(2f)
|
||||
val x = i * stepX + stepX / 2f
|
||||
val yTop = midY - lineH / 2f
|
||||
val yBot = midY + lineH / 2f
|
||||
drawLine(
|
||||
color = if (i <= filledUntil) fillColor else baseColor,
|
||||
start = Offset(x, yTop),
|
||||
end = Offset(x, yBot),
|
||||
strokeWidth = stroke.width,
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user