diff --git a/app/build.gradle b/app/build.gradle index 7fa2d15fd5..caf22f109a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -360,23 +360,23 @@ dependencies { testImplementation 'org.robolectric:shadows-multidex:4.4' implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5' - implementation 'androidx.compose.ui:ui:1.6.2' - implementation 'androidx.compose.animation:animation:1.6.2' - implementation 'androidx.compose.ui:ui-tooling:1.6.2' - implementation "androidx.compose.runtime:runtime-livedata:1.6.2" - implementation 'androidx.compose.foundation:foundation-layout:1.6.2' - implementation 'androidx.compose.material:material:1.6.2' - androidTestImplementation 'androidx.compose.ui:ui-test-junit4-android:1.6.2' - debugImplementation 'androidx.compose.ui:ui-test-manifest:1.6.2' + implementation "androidx.compose.ui:ui:$composeVersion" + implementation "androidx.compose.animation:animation:$composeVersion" + implementation "androidx.compose.ui:ui-tooling:$composeVersion" + implementation "androidx.compose.runtime:runtime-livedata:$composeVersion" + implementation "androidx.compose.foundation:foundation-layout:$composeVersion" + implementation "androidx.compose.material:material:$composeVersion" + androidTestImplementation "androidx.compose.ui:ui-test-junit4-android:$composeVersion" + debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion" implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha" implementation "com.google.accompanist:accompanist-pager:0.33.1-alpha" implementation "com.google.accompanist:accompanist-pager-indicators:0.33.1-alpha" implementation "com.google.accompanist:accompanist-permissions:0.33.1-alpha" - implementation "androidx.camera:camera-camera2:1.3.1" - implementation "androidx.camera:camera-lifecycle:1.3.1" - implementation "androidx.camera:camera-view:1.3.1" + implementation "androidx.camera:camera-camera2:1.3.2" + implementation "androidx.camera:camera-lifecycle:1.3.2" + implementation "androidx.camera:camera-view:1.3.2" implementation 'com.google.firebase:firebase-core:21.1.1' implementation "com.google.mlkit:barcode-scanning:17.2.0" diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/Callbacks.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/Callbacks.kt new file mode 100644 index 0000000000..cd0fa6a33f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dms/Callbacks.kt @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.dms + +interface Callbacks { + fun onChange(value: String) {} + fun onContinue() {} + fun onScan(value: String) {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/EnterPublicKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/EnterPublicKeyFragment.kt index c3afbf5b77..475385d097 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/EnterPublicKeyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dms/EnterPublicKeyFragment.kt @@ -27,9 +27,7 @@ class EnterPublicKeyFragment : Fragment() { var delegate: EnterPublicKeyDelegate? = null private val hexEncodedPublicKey: String - get() { - return TextSecurePreferences.getLocalNumber(requireContext())!! - } + get() = TextSecurePreferences.getLocalNumber(requireContext())!! override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentEnterPublicKeyBinding.inflate(inflater, container, false) diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt index e4859cc71f..fb86012dc0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragment.kt @@ -1,84 +1,85 @@ package org.thoughtcrime.securesms.dms -import android.animation.Animator -import android.animation.AnimatorListenerAdapter import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment -import com.google.android.material.tabs.TabLayoutMediator +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import network.loki.messenger.R -import network.loki.messenger.databinding.FragmentNewMessageBinding -import nl.komponents.kovenant.ui.failUi -import nl.komponents.kovenant.ui.successUi -import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.PublicKeyValidation import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import org.thoughtcrime.securesms.ui.AppTheme +import org.thoughtcrime.securesms.ui.BorderlessButtonSecondary +import org.thoughtcrime.securesms.ui.OutlineButton +import org.thoughtcrime.securesms.ui.PreviewTheme +import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider +import org.thoughtcrime.securesms.ui.components.AppBar +import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode +import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField +import org.thoughtcrime.securesms.ui.components.SessionTabRow @AndroidEntryPoint class NewMessageFragment : Fragment() { - private lateinit var binding: FragmentNewMessageBinding + val viewModel: NewMessageViewModel by viewModels() lateinit var delegate: NewConversationDelegate + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + viewModel.event.collect { + when (it) { + is Event.Success -> createPrivateChat(it.key) + } + } + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - binding = FragmentNewMessageBinding.inflate(inflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.backButton.setOnClickListener { delegate.onDialogBackPressed() } - binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() } - val onsOrPkDelegate = { onsNameOrPublicKey: String -> createPrivateChatIfPossible(onsNameOrPublicKey)} - val adapter = NewMessageFragmentAdapter( - parentFragment = this, - enterPublicKeyDelegate = onsOrPkDelegate, - scanPublicKeyDelegate = onsOrPkDelegate - ) - binding.viewPager.adapter = adapter - val mediator = TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, pos -> - tab.text = when (pos) { - 0 -> getString(R.string.activity_create_private_chat_enter_session_id_tab_title) - 1 -> getString(R.string.activity_create_private_chat_scan_qr_code_tab_title) - else -> throw IllegalStateException() - } - } - mediator.attach() - } - - private fun createPrivateChatIfPossible(onsNameOrPublicKey: String) { - if (PublicKeyValidation.isValid(onsNameOrPublicKey, isPrefixRequired = false)) { - if (PublicKeyValidation.hasValidPrefix(onsNameOrPublicKey)) { - createPrivateChat(onsNameOrPublicKey) - } else { - Toast.makeText(requireContext(), R.string.accountIdErrorInvalid, Toast.LENGTH_SHORT).show() - } - } else { - // This could be an ONS name - showLoader() - SnodeAPI.getSessionID(onsNameOrPublicKey).successUi { hexEncodedPublicKey -> - hideLoader() - createPrivateChat(hexEncodedPublicKey) - }.failUi { exception -> - hideLoader() - val message = when (exception) { - is SnodeAPI.Error.Generic -> "We couldn’t recognize this ONS. Please check and try again." - else -> exception.localizedMessage ?: getString(R.string.fragment_enter_public_key_error_message) - } - Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() + ): View = ComposeView(requireContext()).apply { + setContent { + AppTheme { + val uiState by viewModel.state.collectAsState(State()) + NewMessage( + uiState, + viewModel, + onClose = { delegate.onDialogClosePressed() }, + onBack = { delegate.onDialogBackPressed() }, + onHelp = { Toast.makeText(requireContext(), "todo, not implemented.", Toast.LENGTH_LONG).show() } + ) } } } @@ -92,19 +93,72 @@ class NewMessageFragment : Fragment() { }.let(requireContext()::startActivity) delegate.onDialogClosePressed() } +} - private fun showLoader() { - binding.loader.visibility = View.VISIBLE - binding.loader.animate().setDuration(150).alpha(1.0f).start() +@Preview +@Composable +private fun PreviewNewMessage( + @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int +) { + PreviewTheme(themeResId) { + NewMessage(State(), object: Callbacks {}) } +} - private fun hideLoader() { - binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { +private val TITLES = listOf(R.string.enter_account_id, R.string.qrScan) - override fun onAnimationEnd(animation: Animator) { - super.onAnimationEnd(animation) - binding.loader.visibility = View.GONE +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun NewMessage( + state: State, + callbacks: Callbacks, + onClose: () -> Unit = {}, + onBack: () -> Unit = {}, + onHelp: () -> Unit = {}, +) { + val pagerState = rememberPagerState { TITLES.size } + + Column(modifier = Modifier.background(MaterialTheme.colors.background)) { + AppBar("New Message", onClose = { onClose() }, onBack = { onBack() }) + SessionTabRow(pagerState, TITLES) + HorizontalPager(pagerState) { + when (TITLES[it]) { + R.string.enter_account_id -> EnterAccountId(state, callbacks, onHelp) + R.string.qrScan -> MaybeScanQrCode() } - }) + } } -} \ No newline at end of file +} + +@Composable +fun EnterAccountId( + state: State, + callbacks: Callbacks, + onHelp: () -> Unit = {} +) { + Column( + modifier = Modifier.padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + SessionOutlinedTextField( + text = state.newMessageIdOrOns, + modifier = Modifier.padding(horizontal = 64.dp), + placeholder = "Enter account ID or ONS", + onChange = callbacks::onChange, + onContinue = callbacks::onContinue, + error = state.error?.string() + ) + BorderlessButtonSecondary(text = "Start a new conversation by entering your friend's Account ID, ONS or scanning their QR code.") { onHelp() } + Spacer(modifier = Modifier.weight(1f)) + + OutlineButton( + text = stringResource(id = R.string.continue_2), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(horizontal = 64.dp, vertical = 20.dp) + .width(200.dp), + loading = state.loading + ) { callbacks.onContinue() } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragmentAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragmentAdapter.kt deleted file mode 100644 index 3a07bcb518..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageFragmentAdapter.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.thoughtcrime.securesms.dms - -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment -import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate - -class NewMessageFragmentAdapter( - private val parentFragment: Fragment, - private val enterPublicKeyDelegate: EnterPublicKeyDelegate, - private val scanPublicKeyDelegate: ScanQRCodeWrapperFragmentDelegate -) : FragmentStateAdapter(parentFragment) { - - override fun getItemCount(): Int = 2 - - override fun createFragment(position: Int): Fragment { - return when (position) { - 0 -> EnterPublicKeyFragment().apply { delegate = enterPublicKeyDelegate } - 1 -> ScanQRCodeWrapperFragment().apply { delegate = scanPublicKeyDelegate } - else -> throw IllegalStateException() - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageViewModel.kt new file mode 100644 index 0000000000..5556a89c83 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dms/NewMessageViewModel.kt @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.dms + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import nl.komponents.kovenant.ui.failUi +import nl.komponents.kovenant.ui.successUi +import org.session.libsession.snode.SnodeAPI +import org.session.libsignal.utilities.PublicKeyValidation +import org.thoughtcrime.securesms.ui.GetString +import javax.inject.Inject + +@HiltViewModel +class NewMessageViewModel @Inject constructor( + private val application: Application +): AndroidViewModel(application), Callbacks { + + private val _state = MutableStateFlow( + State() + ) + val state = _state.asStateFlow() + + private val _event = Channel() + val event = _event.receiveAsFlow() + + override fun onChange(value: String) { + _state.update { it.copy( + newMessageIdOrOns = value, + error = null + ) } + } + override fun onContinue() { + createPrivateChatIfPossible(state.value.newMessageIdOrOns) + } + + override fun onScan(value: String) { + createPrivateChatIfPossible(value) + } + + private fun createPrivateChatIfPossible(onsNameOrPublicKey: String) { + if (PublicKeyValidation.isValid(onsNameOrPublicKey, isPrefixRequired = false)) { + if (PublicKeyValidation.hasValidPrefix(onsNameOrPublicKey)) { + onPublicKey(onsNameOrPublicKey) + } else { + _state.update { it.copy(error = GetString(R.string.accountIdErrorInvalid), loading = false) } + } + } else { + // This could be an ONS name + _state.update { it.copy(error = null, loading = true) } + + SnodeAPI.getSessionID(onsNameOrPublicKey).successUi { hexEncodedPublicKey -> + _state.update { it.copy(loading = false) } + onPublicKey(onsNameOrPublicKey) + }.failUi { exception -> + _state.update { it.copy(loading = false, error = GetString(exception) { it.toMessage() }) } + } + } + } + + private fun onPublicKey(onsNameOrPublicKey: String) { + viewModelScope.launch { _event.send(Event.Success(onsNameOrPublicKey)) } + } + + private fun Exception.toMessage() = when (this) { + is SnodeAPI.Error.Generic -> "We couldn’t recognize this ONS. Please check and try again." + else -> localizedMessage ?: application.getString(R.string.fragment_enter_public_key_error_message) + } +} + +data class State( + val newMessageIdOrOns: String = "", + val error: GetString? = null, + val loading: Boolean = false +) + +sealed interface Event { + data class Success(val key: String): Event +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt index a0ec064e71..3ae0ff3253 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/LinkDeviceActivity.kt @@ -3,82 +3,48 @@ package org.thoughtcrime.securesms.onboarding import android.annotation.SuppressLint import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS import androidx.activity.viewModels -import androidx.camera.core.CameraSelector import androidx.camera.core.ExperimentalGetImage -import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis.Analyzer import androidx.camera.core.ImageProxy -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -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.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Scaffold -import androidx.compose.material.Snackbar -import androidx.compose.material.SnackbarHost import androidx.compose.material.Text -import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.lifecycleScope -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState -import com.google.accompanist.permissions.shouldShowRationale import com.google.mlkit.vision.barcode.BarcodeScanner -import com.google.mlkit.vision.barcode.BarcodeScannerOptions -import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.ui.AppTheme import org.thoughtcrime.securesms.ui.OutlineButton import org.thoughtcrime.securesms.ui.baseBold +import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode +import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionTabRow -import org.thoughtcrime.securesms.ui.outlinedTextFieldColors -import java.util.concurrent.Executors import javax.inject.Inject private const val TAG = "LinkDeviceActivity" @@ -94,11 +60,6 @@ class LinkDeviceActivity : BaseActionBarActivity() { val viewModel: LinkDeviceViewModel by viewModels() - val preview = androidx.camera.core.Preview.Builder().build() - val selector = CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) - .build() - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) supportActionBar?.setTitle(R.string.activity_link_load_account) @@ -124,16 +85,12 @@ class LinkDeviceActivity : BaseActionBarActivity() { }.let(::setContentView) } - - @OptIn(ExperimentalFoundationApi::class) @Composable fun LoadAccountScreen(state: LinkDeviceState, onChange: (String) -> Unit = {}, onContinue: () -> Unit = {}) { val pagerState = rememberPagerState { TITLES.size } Column { - val localContext = LocalContext.current - val cameraProvider = remember { ProcessCameraProvider.getInstance(localContext) } SessionTabRow(pagerState, TITLES) HorizontalPager( state = pagerState, @@ -141,114 +98,13 @@ class LinkDeviceActivity : BaseActionBarActivity() { ) { page -> val title = TITLES[page] - val options = BarcodeScannerOptions.Builder() - .setBarcodeFormats(Barcode.FORMAT_QR_CODE) - .build() - val scanner = BarcodeScanning.getClient(options) - - runCatching { - cameraProvider.get().unbindAll() - if (title == R.string.qrScan) { - LocalSoftwareKeyboardController.current?.hide() - cameraProvider.get().bindToLifecycle( - LocalLifecycleOwner.current, - selector, - preview, - buildAnalysisUseCase(scanner, viewModel::scan) - ) - } - }.onFailure { Log.e(TAG, "error binding camera", it) } when (title) { R.string.sessionRecoveryPassword -> RecoveryPassword(state, onChange, onContinue) - R.string.qrScan -> MaybeScanQrCode() + R.string.qrScan -> MaybeScanQrCode(viewModel.qrErrorsFlow) } } } } - - - @OptIn(ExperimentalPermissionsApi::class) - @Composable - fun MaybeScanQrCode() { - Box(modifier = Modifier.fillMaxSize()) { - val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA) - - if (cameraPermissionState.status.isGranted) { - ScanQrCode(preview, viewModel.qrErrorsFlow) - } else if (cameraPermissionState.status.shouldShowRationale) { - Column( - modifier = Modifier - .align(Alignment.Center) - .padding(horizontal = 60.dp) - ) { - Text( - stringResource(R.string.activity_link_camera_permission_permanently_denied_configure_in_settings), - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.size(20.dp)) - OutlineButton( - text = stringResource(R.string.sessionSettings), - modifier = Modifier.align(Alignment.CenterHorizontally) - ) { - Intent(ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", packageName, null) - }.let(::startActivity) - } - } - } else { - OutlineButton( - text = stringResource(R.string.cameraGrantAccess), - modifier = Modifier.align(Alignment.Center) - ) { - cameraPermissionState.run { launchPermissionRequest() } - } - } - } - } - - @Composable - fun ScanQrCode(preview: androidx.camera.core.Preview, errors: Flow) { - val scaffoldState = rememberScaffoldState() - - LaunchedEffect(Unit) { - errors.collect { error -> - lifecycleScope.launch { - scaffoldState.snackbarHostState.showSnackbar(message = error) - } - } - } - - Scaffold( - scaffoldState = scaffoldState, - snackbarHost = { - SnackbarHost( - hostState = scaffoldState.snackbarHostState, - modifier = Modifier.padding(16.dp) - ) { data -> - Snackbar( - snackbarData = data, - modifier = Modifier.padding(16.dp) - ) - } - } - ) { padding -> - Box(modifier = Modifier.padding(padding)) { - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { PreviewView(it).apply { preview.setSurfaceProvider(surfaceProvider) } } - ) - - Box( - Modifier - .aspectRatio(1f) - .padding(20.dp) - .clip(shape = RoundedCornerShape(20.dp)) - .background(Color(0x33ffffff)) - .align(Alignment.Center) - ) - } - } - } } @Preview @@ -272,20 +128,13 @@ fun RecoveryPassword(state: LinkDeviceState, onChange: (String) -> Unit = {}, on Spacer(Modifier.size(28.dp)) Text(stringResource(R.string.activity_link_enter_your_recovery_password_to_load_your_account_if_you_haven_t_saved_it_you_can_find_it_in_your_app_settings)) Spacer(Modifier.size(24.dp)) - OutlinedTextField( - value = state.recoveryPhrase, - onValueChange = { onChange(it) }, - placeholder = { Text(stringResource(R.string.recoveryPasswordEnter)) }, - colors = outlinedTextFieldColors(state.error != null), - singleLine = true, - keyboardActions = KeyboardActions( - onDone = { onContinue() }, - onGo = { onContinue() }, - onSearch = { onContinue() }, - onSend = { onContinue() }, - ), - isError = state.error != null, - shape = RoundedCornerShape(12.dp) + SessionOutlinedTextField( + text = state.recoveryPhrase, + placeholder = stringResource(R.string.recoveryPasswordEnter), + onChange = onChange, + onContinue = onContinue, + error = state.error, + modifier = Modifier.padding(horizontal = 64.dp) ) Spacer(Modifier.size(12.dp)) state.error?.let { @@ -295,9 +144,9 @@ fun RecoveryPassword(state: LinkDeviceState, onChange: (String) -> Unit = {}, on OutlineButton( text = stringResource(id = R.string.continue_2), modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(horizontal = 64.dp, vertical = 20.dp) - .width(200.dp) + .align(Alignment.CenterHorizontally) + .padding(horizontal = 64.dp, vertical = 20.dp) + .width(200.dp) ) { onContinue() } } } @@ -306,16 +155,6 @@ fun Context.startLinkDeviceActivity() { Intent(this, LinkDeviceActivity::class.java).let(::startActivity) } -@SuppressLint("UnsafeOptInUsageError") -private fun buildAnalysisUseCase( - scanner: BarcodeScanner, - onBarcodeScanned: (String) -> Unit -): ImageAnalysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build().apply { - setAnalyzer(Executors.newSingleThreadExecutor(), Analyzer(scanner, onBarcodeScanned)) - } - class Analyzer( private val scanner: BarcodeScanner, private val onBarcodeScanned: (String) -> Unit diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 023353477a..618c874d5e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.ScrollState @@ -25,7 +26,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ButtonColors import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Colors +import androidx.compose.material.ContentAlpha import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton @@ -68,6 +71,7 @@ fun OutlineButton( text: String, modifier: Modifier = Modifier, color: Color = LocalExtraColors.current.prominentButtonColor, + loading: Boolean = false, onClick: () -> Unit ) { OutlinedButton( @@ -80,7 +84,14 @@ fun OutlineButton( backgroundColor = Color.Unspecified ) ) { - Text(text = text) + AnimatedVisibility(loading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = color, + strokeWidth = 2.dp + ) + } + AnimatedVisibility(!loading) { Text(text = text) } } } @@ -99,20 +110,35 @@ fun FilledButton(text: String, modifier: Modifier = Modifier, onClick: () -> Uni } } +@Composable +fun BorderlessButtonSecondary( + text: String, + onClick: () -> Unit +) { + BorderlessButton( + text, + contentColor = MaterialTheme.colors.onSurface.copy(ContentAlpha.medium), + onClick = onClick + ) +} + @Composable fun BorderlessButton( text: String, modifier: Modifier = Modifier, fontSize: TextUnit = TextUnit.Unspecified, lineHeight: TextUnit = TextUnit.Unspecified, - onClick: () -> Unit) { + contentColor: Color = MaterialTheme.colors.onBackground, + backgroundColor: Color = Color.Transparent, + onClick: () -> Unit +) { TextButton( onClick = onClick, modifier = modifier, - shape = RoundedCornerShape(50), // = 50% percent + shape = RoundedCornerShape(percent = 50), colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colors.onBackground, - backgroundColor = MaterialTheme.colors.background + contentColor = contentColor, + backgroundColor = backgroundColor ) ) { Text( @@ -170,15 +196,15 @@ fun ItemButton( ) { TextButton( modifier = Modifier - .fillMaxWidth() - .height(60.dp), + .fillMaxWidth() + .height(60.dp), colors = colors, onClick = onClick, shape = RectangleShape, ) { Box(modifier = Modifier - .width(80.dp) - .fillMaxHeight()) { + .width(80.dp) + .fillMaxHeight()) { Icon( painter = painterResource(id = icon), contentDescription = contentDescription, @@ -209,9 +235,9 @@ fun CellWithPaddingAndMargin( shape = RoundedCornerShape(16.dp), elevation = 0.dp, modifier = Modifier - .wrapContentHeight() - .fillMaxWidth() - .padding(horizontal = margin), + .wrapContentHeight() + .fillMaxWidth() + .padding(horizontal = margin), ) { Box(Modifier.padding(padding)) { content() } } @@ -222,14 +248,14 @@ fun TitledRadioButton(option: RadioOption, onClick: () -> Unit) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .runIf(option.enabled) { clickable { if (!option.selected) onClick() } } - .heightIn(min = 60.dp) - .padding(horizontal = 32.dp) - .contentDescription(option.contentDescription) + .runIf(option.enabled) { clickable { if (!option.selected) onClick() } } + .heightIn(min = 60.dp) + .padding(horizontal = 32.dp) + .contentDescription(option.contentDescription) ) { Column(modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically)) { + .weight(1f) + .align(Alignment.CenterVertically)) { Column { Text( text = option.title(), @@ -250,8 +276,8 @@ fun TitledRadioButton(option: RadioOption, onClick: () -> Unit) { onClick = null, enabled = option.enabled, modifier = Modifier - .height(26.dp) - .align(Alignment.CenterVertically) + .height(26.dp) + .align(Alignment.CenterVertically) ) } } @@ -271,8 +297,9 @@ fun Modifier.contentDescription(id: Int?): Modifier { @Composable fun OutlineButton(text: GetString, contentDescription: GetString? = text, modifier: Modifier = Modifier, onClick: () -> Unit) { OutlinedButton( - modifier = modifier.size(108.dp, 34.dp) - .contentDescription(contentDescription), + modifier = modifier + .size(108.dp, 34.dp) + .contentDescription(contentDescription), onClick = onClick, border = BorderStroke(1.dp, LocalExtraColors.current.prominentButtonColor), shape = RoundedCornerShape(50), // = 50% percent @@ -294,37 +321,37 @@ fun Modifier.fadingEdges( topEdgeHeight: Dp = 0.dp, bottomEdgeHeight: Dp = 20.dp ): Modifier = this.then( - Modifier - // adding layer fixes issue with blending gradient and content - .graphicsLayer { alpha = 0.99F } - .drawWithContent { - drawContent() + Modifier + // adding layer fixes issue with blending gradient and content + .graphicsLayer { alpha = 0.99F } + .drawWithContent { + drawContent() - val topColors = listOf(Color.Transparent, Color.Black) - val topStartY = scrollState.value.toFloat() - val topGradientHeight = min(topEdgeHeight.toPx(), topStartY) - if (topGradientHeight > 0f) drawRect( - brush = Brush.verticalGradient( - colors = topColors, - startY = topStartY, - endY = topStartY + topGradientHeight - ), - blendMode = BlendMode.DstIn - ) + val topColors = listOf(Color.Transparent, Color.Black) + val topStartY = scrollState.value.toFloat() + val topGradientHeight = min(topEdgeHeight.toPx(), topStartY) + if (topGradientHeight > 0f) drawRect( + brush = Brush.verticalGradient( + colors = topColors, + startY = topStartY, + endY = topStartY + topGradientHeight + ), + blendMode = BlendMode.DstIn + ) - val bottomColors = listOf(Color.Black, Color.Transparent) - val bottomEndY = size.height - scrollState.maxValue + scrollState.value - val bottomGradientHeight = - min(bottomEdgeHeight.toPx(), scrollState.maxValue.toFloat() - scrollState.value) - if (bottomGradientHeight > 0f) drawRect( - brush = Brush.verticalGradient( - colors = bottomColors, - startY = bottomEndY - bottomGradientHeight, - endY = bottomEndY - ), - blendMode = BlendMode.DstIn - ) - } + val bottomColors = listOf(Color.Black, Color.Transparent) + val bottomEndY = size.height - scrollState.maxValue + scrollState.value + val bottomGradientHeight = + min(bottomEdgeHeight.toPx(), scrollState.maxValue.toFloat() - scrollState.value) + if (bottomGradientHeight > 0f) drawRect( + brush = Brush.verticalGradient( + colors = bottomColors, + startY = bottomEndY - bottomGradientHeight, + endY = bottomEndY + ), + blendMode = BlendMode.DstIn + ) + } ) @Composable @@ -338,16 +365,16 @@ fun Divider() { fun RowScope.Avatar(recipient: Recipient) { Box( modifier = Modifier - .width(60.dp) - .align(Alignment.CenterVertically) + .width(60.dp) + .align(Alignment.CenterVertically) ) { AndroidView( factory = { ProfilePictureView(it).apply { update(recipient) } }, modifier = Modifier - .width(46.dp) - .height(46.dp) + .width(46.dp) + .height(46.dp) ) } } @@ -374,8 +401,8 @@ fun Arc( ) { Canvas( modifier = modifier - .padding(strokeWidth) - .size(186.dp) + .padding(strokeWidth) + .size(186.dp) ) { // Background Line drawArc( @@ -403,7 +430,8 @@ fun RowScope.SessionShieldIcon() { Icon( painter = painterResource(R.drawable.session_shield), contentDescription = null, - modifier = Modifier.align(Alignment.CenterVertically) - .wrapContentSize(unbounded = true) + modifier = Modifier + .align(Alignment.CenterVertically) + .wrapContentSize(unbounded = true) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt index e472209005..14b68c7b80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt @@ -48,7 +48,7 @@ sealed class GetString { fun GetString(@StringRes resId: Int) = GetString.FromResId(resId) fun GetString(string: String) = GetString.FromString(string) fun GetString(function: (Context) -> String) = GetString.FromFun(function) -fun GetString(value: T, function: (Context, T) -> String) = GetString.FromMap(value, function) +fun GetString(value: T, function: Context.(T) -> String) = GetString.FromMap(value, function) fun GetString(duration: Duration) = GetString.FromMap(duration, ExpirationUtil::getExpirationDisplayValue) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt new file mode 100644 index 0000000000..9e29d2379d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/AppBar.kt @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import network.loki.messenger.R + +@Composable +fun AppBar(title: String, onClose: () -> Unit = {}, onBack: (() -> Unit)? = null) { + Row(modifier = Modifier.height(64.dp), verticalAlignment = Alignment.CenterVertically) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(64.dp)) { + onBack?.let { + IconButton(onClick = it) { + Icon(painter = painterResource(id = R.drawable.ic_prev), contentDescription = "back") + } + } + } + Spacer(modifier = Modifier.weight(1f)) + Text(text = title, style = MaterialTheme.typography.h4) + Spacer(modifier = Modifier.weight(1f)) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(64.dp)) { + IconButton(onClick = onClose) { + Icon(painter = painterResource(id = R.drawable.ic_x), contentDescription = "back") + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt new file mode 100644 index 0000000000..8c2143168e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt @@ -0,0 +1,195 @@ +package org.thoughtcrime.securesms.ui.components + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Snackbar +import androidx.compose.material.SnackbarHost +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import network.loki.messenger.R +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.onboarding.Analyzer +import org.thoughtcrime.securesms.ui.OutlineButton +import java.util.concurrent.Executors + +typealias CameraPreview = androidx.camera.core.Preview + +private const val TAG = "NewMessageFragment" + +@Composable +fun MaybeScanQrCode(errors: Flow = emptyFlow()) { + LocalContext.current.run { + MaybeScanQrCode(onScan = {}) + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun MaybeScanQrCode( + errors: Flow = emptyFlow(), + onClickSettings: () -> Unit = LocalContext.current.run { { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + }.let(::startActivity) + } }, + onScan: (String) -> Unit = {} +) { + Box(modifier = Modifier.fillMaxSize()) { + LocalSoftwareKeyboardController.current?.hide() + + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + + if (cameraPermissionState.status.isGranted) { + ScanQrCode(errors, onScan) + } else if (cameraPermissionState.status.shouldShowRationale) { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 60.dp) + ) { + Text( + stringResource(R.string.activity_link_camera_permission_permanently_denied_configure_in_settings), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.size(20.dp)) + OutlineButton( + text = stringResource(R.string.sessionSettings), + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = onClickSettings + ) + } + } else { + OutlineButton( + text = stringResource(R.string.cameraGrantAccess), + modifier = Modifier.align(Alignment.Center) + ) { + cameraPermissionState.run { launchPermissionRequest() } + } + } + } +} + +@Composable +fun ScanQrCode(errors: Flow, onScan: (String) -> Unit) { + val localContext = LocalContext.current + val cameraProvider = remember { ProcessCameraProvider.getInstance(localContext) } + + val preview = androidx.camera.core.Preview.Builder().build() + val selector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + + runCatching { + cameraProvider.get().unbindAll() + + val options = BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + val scanner = BarcodeScanning.getClient(options) + + cameraProvider.get().bindToLifecycle( + LocalLifecycleOwner.current, + selector, + preview, + buildAnalysisUseCase(scanner, onScan) + ) + }.onFailure { Log.e(TAG, "error binding camera", it) } + + DisposableEffect(key1 = cameraProvider) { + onDispose { + cameraProvider.get().unbindAll() + } + } + + val scaffoldState = rememberScaffoldState() + + LaunchedEffect(Unit) { + errors.collect { error -> + scaffoldState.snackbarHostState.showSnackbar(message = error) + } + } + + Scaffold( + scaffoldState = scaffoldState, + snackbarHost = { + SnackbarHost( + hostState = scaffoldState.snackbarHostState, + modifier = Modifier.padding(16.dp) + ) { data -> + Snackbar( + snackbarData = data, + modifier = Modifier.padding(16.dp) + ) + } + } + ) { padding -> + Box(modifier = Modifier.padding(padding)) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { PreviewView(it).apply { preview.setSurfaceProvider(surfaceProvider) } } + ) + + Box( + Modifier + .aspectRatio(1f) + .padding(20.dp) + .clip(shape = RoundedCornerShape(20.dp)) + .background(Color(0x33ffffff)) + .align(Alignment.Center) + ) + } + } +} + +@SuppressLint("UnsafeOptInUsageError") +private fun buildAnalysisUseCase( + scanner: BarcodeScanner, + onBarcodeScanned: (String) -> Unit +): ImageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build().apply { + setAnalyzer(Executors.newSingleThreadExecutor(), Analyzer(scanner, onBarcodeScanned)) + } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt new file mode 100644 index 0000000000..86bf5153ea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Text.kt @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.thoughtcrime.securesms.ui.baseBold +import org.thoughtcrime.securesms.ui.outlinedTextFieldColors + +@Composable +fun SessionOutlinedTextField( + text: String, + placeholder: String, + onChange: (String) -> Unit, + onContinue: () -> Unit, + error: String? = null, + modifier: Modifier +) { + Column(modifier = modifier) { + OutlinedTextField( + value = text, + onValueChange = { onChange(it) }, + placeholder = { Text(placeholder) }, + colors = outlinedTextFieldColors(error != null), + singleLine = true, + keyboardActions = KeyboardActions( + onDone = { onContinue() }, + onGo = { onContinue() }, + onSearch = { onContinue() }, + onSend = { onContinue() }, + ), + isError = error != null, + shape = RoundedCornerShape(12.dp) + ) + error?.let { + Text( + it, + modifier = Modifier.padding(top = 12.dp), + style = MaterialTheme.typography.baseBold, + color = MaterialTheme.colors.error + ) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 194fe768ed..f3be517011 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1082,4 +1082,5 @@ Camera Permission permanently denied. Configure in settings. Enter your recovery password to load your account. If you haven\'t saved it, you can find it in your app settings. This Account ID is invalid. Please check and try again. + Enter Account ID diff --git a/gradle.properties b/gradle.properties index e6dd30852e..6f3a80a7fb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,6 +22,7 @@ kotlinVersion=1.8.21 android.useAndroidX=true appcompatVersion=1.6.1 coreVersion=1.8.0 +composeVersion=1.6.4 coroutinesVersion=1.6.4 curve25519Version=0.6.0 daggerVersion=2.46.1 @@ -30,7 +31,7 @@ jacksonDatabindVersion=2.9.8 junitVersion=4.13.2 kotlinxJsonVersion=1.3.3 kovenantVersion=3.3.0 -lifecycleVersion=2.5.1 +lifecycleVersion=2.7.0 materialVersion=1.8.0 mockitoKotlinVersion=4.1.0 okhttpVersion=3.12.1