diff --git a/app/src/main/java/com/critt/interp/ui/MainActivity.kt b/app/src/main/java/com/critt/interp/ui/MainActivity.kt index 1c4b270..828bdce 100644 --- a/app/src/main/java/com/critt/interp/ui/MainActivity.kt +++ b/app/src/main/java/com/critt/interp/ui/MainActivity.kt @@ -4,9 +4,9 @@ import android.Manifest import android.content.pm.PackageManager import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -16,16 +16,18 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel import com.critt.data.ApiResult +import com.critt.domain.LanguageData import com.critt.domain.Speaker import com.critt.interp.ui.components.DropdownSelector import com.critt.ui_common.theme.InterpTheme @@ -34,59 +36,34 @@ import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { - - //TODO: Refactor to use androidx.lifecycle.viewmodel.compose.viewModel() - private val viewModel: MainViewModel by viewModels() - - private val requestPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted: Boolean -> - if (isGranted) { - startRecording() - } else { - // Handle the case where the user denied the permission - // You can show a message or disable the functionality that requires the permission - } - } - - private fun startRecording() { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == - PackageManager.PERMISSION_GRANTED - ) { - viewModel.startRecording() - } else { - requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { InterpTheme { - //TODO: Refactor to use androidx.lifecycle.viewmodel.compose.viewModel() - //val viewModel = viewModel() - MainView(viewModel) + MainView(viewModel()) } } } @Composable - fun TranslationGroup(speaker: Speaker, interactionSource: MutableInteractionSource? = null) { + fun TranslationGroup( + translationText: String = "", + speaker: Speaker, + langSubject: LanguageData, + langObject: LanguageData, + interactionSource: MutableInteractionSource? = null + ) { InterpTheme { Column(modifier = Modifier.padding(16.dp)) { - LanguageDisplay(speaker) + LanguageDisplay(speaker, langSubject, langObject) Spacer(modifier = Modifier.height(12.dp)) - OutputCard(speaker, interactionSource) + OutputCard(translationText, interactionSource) } } } @Composable - fun LanguageDisplay(speaker: Speaker) { - //TODO: State Hoisting - val langSubject = viewModel.langSubject - val langObject = viewModel.langObject - + fun LanguageDisplay(speaker: Speaker, langSubject: LanguageData, langObject: LanguageData) { InterpTheme { Row { Text( @@ -111,13 +88,7 @@ class MainActivity : ComponentActivity() { } @Composable - fun OutputCard(user: Speaker, interactionSource: MutableInteractionSource? = null) { - //TODO: State Hoisting - val output by when (user) { - Speaker.SUBJECT -> viewModel.translationObject.collectAsState() - Speaker.OBJECT -> viewModel.translationSubject.collectAsState() - } - + fun OutputCard(translationText: String, interactionSource: MutableInteractionSource? = null) { InterpTheme { Surface( modifier = Modifier @@ -130,7 +101,7 @@ class MainActivity : ComponentActivity() { shadowElevation = 4.dp ) { Text( - output ?: "", + text = translationText, modifier = Modifier.padding(8.dp), color = MaterialTheme.colorScheme.onSurface ) @@ -142,20 +113,60 @@ class MainActivity : ComponentActivity() { @Composable @Preview fun MainView(viewModel: MainViewModel = viewModel()) { - // StateFlow - val supportedLanguages by viewModel.supportedLanguages.collectAsState() - // TODO: Refactor to use StateFlow - // Compose State - val langSubject = remember { viewModel.langSubject } - val langObject = remember { viewModel.langObject } + val context = LocalContext.current + + // State to track if the audio recording permission is granted + var hasRecordingPermission by rememberSaveable { + mutableStateOf( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + ) + } - //TODO: Refactor to use StateFlow - val isConnected by viewModel.isConnected.observeAsState(false) + val requestPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { isGranted -> + hasRecordingPermission = isGranted + } + ) - //TODO: Refactor to use lambda arguments for interactionSource callbacks passed down to OutputCard - //TODO: callbacks should update speakerCurr state: viewModel.speakerCurr = if (isPressed) Speaker.SUBJECT else Speaker.OBJECT + // ViewModel StateFlows + val supportedLanguages by viewModel.supportedLanguages.collectAsState() + val langSubject by viewModel.langSubject.collectAsState() + val langObject by viewModel.langObject.collectAsState() + val translationSubject by viewModel.translationSubject.collectAsState() + val translationObject by viewModel.translationObject.collectAsState() + val streamingState by viewModel.streamingState.collectAsState() + + /** Local state -> LaunchedEffect -> ViewModel StateFlow */ + // Language selector for Subject speaker + var uiSelectedLangSubject by remember { mutableStateOf(langSubject) } + LaunchedEffect(uiSelectedLangSubject) { + viewModel.selectLangSubject(uiSelectedLangSubject) + } + // Language selector for Object speaker + var uiSelectedLangObject by remember { mutableStateOf(langObject) } + LaunchedEffect(uiSelectedLangObject) { + viewModel.selectLangObject(uiSelectedLangObject) + } + // Interaction source (pressing down on the lower OutputCard) for current Speaker val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() + LaunchedEffect(isPressed) { + viewModel.updateSpeaker(subjectSpeaking = isPressed) + } + // Streaming state toggle (FAB) + var toggleSideEffect by remember { mutableStateOf<(() -> Unit)?>(null) } + LaunchedEffect(toggleSideEffect, hasRecordingPermission) { + if (!hasRecordingPermission) { + requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } else { + toggleSideEffect?.invoke() + toggleSideEffect = null + } + } InterpTheme { Column( @@ -168,12 +179,20 @@ class MainActivity : ComponentActivity() { .weight(.40F) .rotate(180F) ) { - TranslationGroup(Speaker.OBJECT) + TranslationGroup( + translationText = translationObject, + speaker = Speaker.OBJECT, + langSubject = langSubject, + langObject = langObject, + ) } Box(modifier = Modifier.weight(.40F)) { TranslationGroup( - Speaker.SUBJECT, - interactionSource + translationText = translationSubject, + speaker = Speaker.SUBJECT, + langSubject = langSubject, + langObject = langObject, + interactionSource = interactionSource ) } Row( @@ -185,10 +204,12 @@ class MainActivity : ComponentActivity() { ) { Box(modifier = Modifier.weight(.375F)) { DropdownSelector( - options = (supportedLanguages as? ApiResult.Success)?.data ?: emptyList(), + options = (supportedLanguages as? ApiResult.Success)?.data + ?: emptyList(), selectedOption = langSubject, onOptionSelected = { selectedOption -> - viewModel.updateLangSubject(selectedOption) + // Update the local UI state + uiSelectedLangSubject = selectedOption } ) } @@ -197,35 +218,27 @@ class MainActivity : ComponentActivity() { Spacer(modifier = Modifier.width(8.dp)) Box(modifier = Modifier.weight(.375F)) { DropdownSelector( - options = (supportedLanguages as? ApiResult.Success)?.data ?: emptyList(), + options = (supportedLanguages as? ApiResult.Success)?.data + ?: emptyList(), selectedOption = langObject, onOptionSelected = { selectedOption -> - viewModel.updateLangObject(selectedOption) + // Update the local UI state + uiSelectedLangObject = selectedOption } ) } Spacer(modifier = Modifier.width(8.dp)) FloatingActionButton( onClick = { - when (isConnected) { - true -> { - viewModel.stopRecording() - viewModel.disconnect() - } - - false -> { - if (viewModel.connect()) { - startRecording() - } - } - } + toggleSideEffect = { viewModel.toggleStreaming() } }, modifier = Modifier.weight(.15F) ) { Text( - "☁", color = when (isConnected) { - true -> MaterialTheme.colorScheme.onSecondary - false -> MaterialTheme.colorScheme.onPrimary + "☁", color = when (streamingState) { + AudioStreamingState.Streaming -> MaterialTheme.colorScheme.onSecondary + AudioStreamingState.Idle -> MaterialTheme.colorScheme.onPrimary + is AudioStreamingState.Error -> MaterialTheme.colorScheme.onError } ) } diff --git a/app/src/main/java/com/critt/interp/ui/MainViewModel.kt b/app/src/main/java/com/critt/interp/ui/MainViewModel.kt index 2679abc..432c5b2 100644 --- a/app/src/main/java/com/critt/interp/ui/MainViewModel.kt +++ b/app/src/main/java/com/critt/interp/ui/MainViewModel.kt @@ -1,9 +1,5 @@ package com.critt.interp.ui -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.critt.data.ApiResult @@ -12,9 +8,11 @@ import com.critt.domain.LanguageData import com.critt.data.SessionManager import com.critt.domain.Speaker import com.critt.data.TranslationRepository +import com.critt.domain.SpeechData import com.critt.domain.defaultLangObject import com.critt.domain.defaultLangSubject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -29,35 +27,33 @@ class MainViewModel @Inject constructor( private val translationRepo: TranslationRepository, private val sessionManager: SessionManager, private val audioSource: AudioSource -) : - ViewModel() { +) : ViewModel() { + // Supported languages state + private val _supportedLanguages = + MutableStateFlow>>(ApiResult.Loading) + val supportedLanguages = _supportedLanguages.asStateFlow() - private val builderObject = StringBuilder() - private val builderSubject = StringBuilder() + // Current speaker state + private val _speakerCurr = MutableStateFlow(Speaker.OBJECT) + val speakerCurr = _speakerCurr.asStateFlow() - // StateFlow for translations + // Translation output state private val _translationSubject = MutableStateFlow("") val translationSubject = _translationSubject.asStateFlow() private val _translationObject = MutableStateFlow("") val translationObject = _translationObject.asStateFlow() + private val builderObject = StringBuilder() + private val builderSubject = StringBuilder() - // StateFlow for supported languages - private val _supportedLanguages = - MutableStateFlow>>(ApiResult.Loading) - val supportedLanguages = _supportedLanguages.asStateFlow() - - // TODO: Refactor to use StateFlow - // Compose State for selected languages - var langSubject by mutableStateOf(defaultLangSubject) - private set - var langObject by mutableStateOf(defaultLangObject) - private set - - var isConnected = MutableLiveData(false) //TODO: this makes no sense - private var jobRecord: Job? = null + // Selected languages state + private val _langSubject = MutableStateFlow(defaultLangSubject) + val langSubject = _langSubject.asStateFlow() + private val _langObject = MutableStateFlow(defaultLangObject) + val langObject = _langObject.asStateFlow() - //TODO: Refactor to use StateFlow - var speakerCurr = Speaker.SUBJECT + // Streaming state + private val _streamingState = MutableStateFlow(AudioStreamingState.Idle) + val streamingState = _streamingState.asStateFlow() init { viewModelScope.launch(context = Dispatchers.IO) { @@ -67,83 +63,148 @@ class MainViewModel @Inject constructor( } } - fun updateLangSubject(lang: LanguageData) { - langSubject = lang + fun updateSpeaker(subjectSpeaking: Boolean) { + when (subjectSpeaking) { + true -> _speakerCurr.update { Speaker.SUBJECT } + false -> _speakerCurr.update { Speaker.OBJECT } + } + } + + fun selectLangSubject(lang: LanguageData) { + _langSubject.update { lang } } - fun updateLangObject(lang: LanguageData) { - langObject = lang + fun selectLangObject(lang: LanguageData) { + _langObject.update { lang } } - fun connect(): Boolean { - isConnected.postValue(true) //TODO: this makes no sense + fun toggleStreaming() { + when (_streamingState.value) { + is AudioStreamingState.Idle -> startRecordingAndStreaming() + is AudioStreamingState.Streaming -> { + stopRecordingAndStreaming() + _streamingState.update { AudioStreamingState.Idle } + } + + is AudioStreamingState.Error -> { + stopRecordingAndStreaming() + startRecordingAndStreaming() + } + } + } + + private fun startRecordingAndStreaming() { + // clear the translation output StringBuilders builderSubject.clear() builderObject.clear() + // open socket connection on the "subject" namespace viewModelScope.launch(context = Dispatchers.IO) { - translationRepo.connectSubject( - langSubject.language, - langObject.language - ) - .collect { res -> - //TODO: This should be a lambda argument - Timber.d("speakerCurr: $speakerCurr") - if (res.isFinal) { - builderSubject.append(res.data) - _translationSubject.update { builderSubject.toString() } - } else { - _translationSubject.update { builderSubject.toString() + res.data } - } + try { + translationRepo.connectSubject( + languageSubject = langSubject.value.language, + languageObject = langObject.value.language + ).collect { res -> + onTextData( + textData = res, + translationState = _translationSubject, + builder = builderSubject + ) } + } catch (e: Exception) { + stopRecordingAndStreaming() + _streamingState.update { + AudioStreamingState.Error( + e.message ?: "subject socket: unknown error" + ) + } + } } + // open socket connection on the "object" namespace viewModelScope.launch(context = Dispatchers.IO) { - translationRepo.connectObject( - langSubject.language, - langObject.language - ) - .collect { res -> - //TODO: This should be a lambda argument - Timber.d("speakerCurr: $speakerCurr") - if (res.isFinal) { - builderObject.append(res.data) - _translationObject.update { builderObject.toString() } - } else { - _translationObject.update { builderObject.toString() + res.data } - } + try { + translationRepo.connectObject( + languageSubject = langSubject.value.language, + languageObject = langObject.value.language + ).collect { res -> + onTextData( + textData = res, + translationState = _translationObject, + builder = builderObject + ) } + } catch (e: Exception) { + stopRecordingAndStreaming() + _streamingState.update { + AudioStreamingState.Error( + e.message ?: "object socket: unknown error" + ) + } + } } - return true - } - - fun startRecording() { - // TODO: Handle this state better - if (jobRecord == null) { - jobRecord = viewModelScope.launch(context = Dispatchers.IO) { - audioSource.startRecording(::handleInput) + // start recording + viewModelScope.launch(context = Dispatchers.IO) { + try { + audioSource.startRecording(onData = ::onAudioData) + } catch (e: Exception) { + _streamingState.update { + AudioStreamingState.Error( + e.message ?: "audioSource job: unknown error" + ) + } + } + }.invokeOnCompletion { cause -> + stopRecordingAndStreaming() + when (cause) { + null, is CancellationException -> _streamingState.update { AudioStreamingState.Idle } + else -> _streamingState.update { + AudioStreamingState.Error( + cause.message ?: "audioSource job completion handler: unknown error" + ) + } } } + + _streamingState.update { AudioStreamingState.Streaming } } - fun stopRecording() { - // TODO: Handle this state better + private fun stopRecordingAndStreaming() { audioSource.stopRecording() - jobRecord?.cancel() - jobRecord = null + translationRepo.disconnect() } - fun handleInput(data: ByteArray) { - if (speakerCurr == Speaker.SUBJECT) { - translationRepo.onData(data, ByteArray(2048)) - } else { - translationRepo.onData(ByteArray(2048), data) + fun onAudioData(data: ByteArray) { + when (speakerCurr.value) { + Speaker.SUBJECT -> translationRepo.onData( + subjectData = data, + objectData = ByteArray(2048) + ) + + Speaker.OBJECT -> translationRepo.onData( + subjectData = ByteArray(2048), + objectData = data + ) } } - fun disconnect() { - // TODO: Handle this state better - isConnected.postValue(false)//TODO: this makes no sense - translationRepo.disconnect() + private fun onTextData( + textData: SpeechData, + translationState: MutableStateFlow, + builder: StringBuilder + ) { + if (textData.isFinal) { + builder.append(textData.data) + translationState.update { builder.toString() } + } else { + translationState.update { builder.toString() + textData.data } + } } +} + +sealed class AudioStreamingState { + object Idle : AudioStreamingState() + object Streaming : AudioStreamingState() + data class Error(val message: String) : AudioStreamingState() } \ No newline at end of file