mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 10:45:21 +00:00
Media transfers (#440)
* tor voice wip * worky BLE a bit * can send sound * remove tor * ui cleanup * recording time * progress bar color * nicknames for audio * onboarding permissions no microphone * may work * fix destionation * extend * refactor voice input component * fix keyboard collapse issue * send images * wip image open * image sending works * wip waveforms * better * better animation * fix cursor for sending audio * image sending animation * image sending animation * full screen image viewer * gossip sync for fragments too * reduce delays * fix keyboard focus * use v2 for file transfers * do not sync fragments * scrollable image viewer * ui * ui adjustments * nicer animation * seek through audio * add spec * add more details to documentation * File sharing E2E: - Add TLV BitchatFilePacket, FileSharingManager - Implement sendFileNote in ChatViewModel - File receive path: save to files/incoming and render [file] messages with FileMessageItem or FileSendingAnimation during transfer - SAF FilePickerButton and dispatcher wiring; image/file choice to follow in MediaPickerOptions - Add FileViewerDialog with system open/save, FileProvider and file_paths - Hook transfer progress to file sending UI - Manifest: READ_MEDIA_* and FileProvider - Fix MessageHandler saving and prefix for non-image payloads - Add helper utils (FileUtils) * kinda wip * fix buttons * files half working * wip file transfer * file packet has 2-byte TLV and chunks. it wokrs but it sucks * clean * remove gossip sync for fragments * fix audio and image rendering * adjust FILE_SIZE TLV size too * cleanup * haptic * private messages media * read receipts for media * use enum for message type not string * delivery ack checks dont push content * check * animation fix * refactor * ui adjustments * comments * refactor * fix crash on send and receive of the same file * refactor notifications * tests
This commit is contained in:
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user