Update New Message Screen

This commit is contained in:
Andrew 2024-04-03 09:48:02 +10:30
parent e25b90b229
commit f89e4705d5
14 changed files with 611 additions and 338 deletions

View File

@ -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"

View File

@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.dms
interface Callbacks {
fun onChange(value: String) {}
fun onContinue() {}
fun onScan(value: String) {}
}

View File

@ -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)

View File

@ -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
): 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() }
)
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 couldnt 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()
}
}
}
@ -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()
}
private fun hideLoader() {
binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() {
@Preview
@Composable
private fun PreviewNewMessage(
@PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int
) {
PreviewTheme(themeResId) {
NewMessage(State(), object: Callbacks {})
}
}
override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
binding.loader.visibility = View.GONE
}
})
private val TITLES = listOf(R.string.enter_account_id, R.string.qrScan)
@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()
}
}
}
}
@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() }
}
}

View File

@ -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()
}
}
}

View File

@ -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<Event>()
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 couldnt 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
}

View File

@ -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<String>) {
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 {
@ -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

View File

@ -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(
@ -271,7 +297,8 @@ 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)
modifier = modifier
.size(108.dp, 34.dp)
.contentDescription(contentDescription),
onClick = onClick,
border = BorderStroke(1.dp, LocalExtraColors.current.prominentButtonColor),
@ -403,7 +430,8 @@ fun RowScope.SessionShieldIcon() {
Icon(
painter = painterResource(R.drawable.session_shield),
contentDescription = null,
modifier = Modifier.align(Alignment.CenterVertically)
modifier = Modifier
.align(Alignment.CenterVertically)
.wrapContentSize(unbounded = true)
)
}

View File

@ -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 <T> GetString(value: T, function: (Context, T) -> String) = GetString.FromMap(value, function)
fun <T> GetString(value: T, function: Context.(T) -> String) = GetString.FromMap(value, function)
fun GetString(duration: Duration) = GetString.FromMap(duration, ExpirationUtil::getExpirationDisplayValue)

View File

@ -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")
}
}
}
}

View File

@ -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<String> = emptyFlow()) {
LocalContext.current.run {
MaybeScanQrCode(onScan = {})
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MaybeScanQrCode(
errors: Flow<String> = 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<String>, 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))
}

View File

@ -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
)
}
}
}

View File

@ -1082,4 +1082,5 @@
<string name="activity_link_camera_permission_permanently_denied_configure_in_settings">Camera Permission permanently denied. Configure in settings.</string>
<string name="activity_link_enter_your_recovery_password_to_load_your_account_if_you_haven_t_saved_it_you_can_find_it_in_your_app_settings">Enter your recovery password to load your account. If you haven\'t saved it, you can find it in your app settings.</string>
<string name="accountIdErrorInvalid">This Account ID is invalid. Please check and try again.</string>
<string name="enter_account_id">Enter Account ID</string>
</resources>

View File

@ -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