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' testImplementation 'org.robolectric:shadows-multidex:4.4'
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5' implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
implementation 'androidx.compose.ui:ui:1.6.2' implementation "androidx.compose.ui:ui:$composeVersion"
implementation 'androidx.compose.animation:animation:1.6.2' implementation "androidx.compose.animation:animation:$composeVersion"
implementation 'androidx.compose.ui:ui-tooling:1.6.2' implementation "androidx.compose.ui:ui-tooling:$composeVersion"
implementation "androidx.compose.runtime:runtime-livedata:1.6.2" implementation "androidx.compose.runtime:runtime-livedata:$composeVersion"
implementation 'androidx.compose.foundation:foundation-layout:1.6.2' implementation "androidx.compose.foundation:foundation-layout:$composeVersion"
implementation 'androidx.compose.material:material:1.6.2' implementation "androidx.compose.material:material:$composeVersion"
androidTestImplementation 'androidx.compose.ui:ui-test-junit4-android:1.6.2' androidTestImplementation "androidx.compose.ui:ui-test-junit4-android:$composeVersion"
debugImplementation 'androidx.compose.ui:ui-test-manifest:1.6.2' debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion"
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha" 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:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-pager-indicators: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 "com.google.accompanist:accompanist-permissions:0.33.1-alpha"
implementation "androidx.camera:camera-camera2:1.3.1" implementation "androidx.camera:camera-camera2:1.3.2"
implementation "androidx.camera:camera-lifecycle:1.3.1" implementation "androidx.camera:camera-lifecycle:1.3.2"
implementation "androidx.camera:camera-view:1.3.1" implementation "androidx.camera:camera-view:1.3.2"
implementation 'com.google.firebase:firebase-core:21.1.1' implementation 'com.google.firebase:firebase-core:21.1.1'
implementation "com.google.mlkit:barcode-scanning:17.2.0" 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 var delegate: EnterPublicKeyDelegate? = null
private val hexEncodedPublicKey: String private val hexEncodedPublicKey: String
get() { get() = TextSecurePreferences.getLocalNumber(requireContext())!!
return TextSecurePreferences.getLocalNumber(requireContext())!!
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentEnterPublicKeyBinding.inflate(inflater, container, false) binding = FragmentEnterPublicKeyBinding.inflate(inflater, container, false)

View File

@ -1,84 +1,85 @@
package org.thoughtcrime.securesms.dms package org.thoughtcrime.securesms.dms
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast 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 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 dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import network.loki.messenger.R 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.Address
import org.session.libsession.utilities.recipients.Recipient 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.start.NewConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.dependencies.DatabaseComponent 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 @AndroidEntryPoint
class NewMessageFragment : Fragment() { class NewMessageFragment : Fragment() {
private lateinit var binding: FragmentNewMessageBinding val viewModel: NewMessageViewModel by viewModels()
lateinit var delegate: NewConversationDelegate 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( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View = ComposeView(requireContext()).apply {
binding = FragmentNewMessageBinding.inflate(inflater) setContent {
return binding.root AppTheme {
} val uiState by viewModel.state.collectAsState(State())
NewMessage(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { uiState,
super.onViewCreated(view, savedInstanceState) viewModel,
binding.backButton.setOnClickListener { delegate.onDialogBackPressed() } onClose = { delegate.onDialogClosePressed() },
binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() } onBack = { delegate.onDialogBackPressed() },
val onsOrPkDelegate = { onsNameOrPublicKey: String -> createPrivateChatIfPossible(onsNameOrPublicKey)} onHelp = { Toast.makeText(requireContext(), "todo, not implemented.", Toast.LENGTH_LONG).show() }
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 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) }.let(requireContext()::startActivity)
delegate.onDialogClosePressed() delegate.onDialogClosePressed()
} }
private fun showLoader() {
binding.loader.visibility = View.VISIBLE
binding.loader.animate().setDuration(150).alpha(1.0f).start()
} }
private fun hideLoader() { @Preview
binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() { @Composable
private fun PreviewNewMessage(
@PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int
) {
PreviewTheme(themeResId) {
NewMessage(State(), object: Callbacks {})
}
}
override fun onAnimationEnd(animation: Animator) { private val TITLES = listOf(R.string.enter_account_id, R.string.qrScan)
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()
}
}
}
}
@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.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageAnalysis.Analyzer import androidx.camera.core.ImageAnalysis.Analyzer
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.ExperimentalFoundationApi 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState 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.Icon
import androidx.compose.material.MaterialTheme 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.Text
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.lifecycleScope 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.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.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.common.InputImage
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.BaseActionBarActivity import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.ui.AppTheme import org.thoughtcrime.securesms.ui.AppTheme
import org.thoughtcrime.securesms.ui.OutlineButton import org.thoughtcrime.securesms.ui.OutlineButton
import org.thoughtcrime.securesms.ui.baseBold 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.components.SessionTabRow
import org.thoughtcrime.securesms.ui.outlinedTextFieldColors
import java.util.concurrent.Executors
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "LinkDeviceActivity" private const val TAG = "LinkDeviceActivity"
@ -94,11 +60,6 @@ class LinkDeviceActivity : BaseActionBarActivity() {
val viewModel: LinkDeviceViewModel by viewModels() 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
supportActionBar?.setTitle(R.string.activity_link_load_account) supportActionBar?.setTitle(R.string.activity_link_load_account)
@ -124,16 +85,12 @@ class LinkDeviceActivity : BaseActionBarActivity() {
}.let(::setContentView) }.let(::setContentView)
} }
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun LoadAccountScreen(state: LinkDeviceState, onChange: (String) -> Unit = {}, onContinue: () -> Unit = {}) { fun LoadAccountScreen(state: LinkDeviceState, onChange: (String) -> Unit = {}, onContinue: () -> Unit = {}) {
val pagerState = rememberPagerState { TITLES.size } val pagerState = rememberPagerState { TITLES.size }
Column { Column {
val localContext = LocalContext.current
val cameraProvider = remember { ProcessCameraProvider.getInstance(localContext) }
SessionTabRow(pagerState, TITLES) SessionTabRow(pagerState, TITLES)
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
@ -141,114 +98,13 @@ class LinkDeviceActivity : BaseActionBarActivity() {
) { page -> ) { page ->
val title = TITLES[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) { when (title) {
R.string.sessionRecoveryPassword -> RecoveryPassword(state, onChange, onContinue) 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 @Preview
@ -272,20 +128,13 @@ fun RecoveryPassword(state: LinkDeviceState, onChange: (String) -> Unit = {}, on
Spacer(Modifier.size(28.dp)) 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)) 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)) Spacer(Modifier.size(24.dp))
OutlinedTextField( SessionOutlinedTextField(
value = state.recoveryPhrase, text = state.recoveryPhrase,
onValueChange = { onChange(it) }, placeholder = stringResource(R.string.recoveryPasswordEnter),
placeholder = { Text(stringResource(R.string.recoveryPasswordEnter)) }, onChange = onChange,
colors = outlinedTextFieldColors(state.error != null), onContinue = onContinue,
singleLine = true, error = state.error,
keyboardActions = KeyboardActions( modifier = Modifier.padding(horizontal = 64.dp)
onDone = { onContinue() },
onGo = { onContinue() },
onSearch = { onContinue() },
onSend = { onContinue() },
),
isError = state.error != null,
shape = RoundedCornerShape(12.dp)
) )
Spacer(Modifier.size(12.dp)) Spacer(Modifier.size(12.dp))
state.error?.let { state.error?.let {
@ -306,16 +155,6 @@ fun Context.startLinkDeviceActivity() {
Intent(this, LinkDeviceActivity::class.java).let(::startActivity) 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( class Analyzer(
private val scanner: BarcodeScanner, private val scanner: BarcodeScanner,
private val onBarcodeScanned: (String) -> Unit private val onBarcodeScanned: (String) -> Unit

View File

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.ui package org.thoughtcrime.securesms.ui
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
@ -25,7 +26,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ButtonColors import androidx.compose.material.ButtonColors
import androidx.compose.material.ButtonDefaults import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Card import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Colors import androidx.compose.material.Colors
import androidx.compose.material.ContentAlpha
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton import androidx.compose.material.OutlinedButton
@ -68,6 +71,7 @@ fun OutlineButton(
text: String, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
color: Color = LocalExtraColors.current.prominentButtonColor, color: Color = LocalExtraColors.current.prominentButtonColor,
loading: Boolean = false,
onClick: () -> Unit onClick: () -> Unit
) { ) {
OutlinedButton( OutlinedButton(
@ -80,7 +84,14 @@ fun OutlineButton(
backgroundColor = Color.Unspecified 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 @Composable
fun BorderlessButton( fun BorderlessButton(
text: String, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
fontSize: TextUnit = TextUnit.Unspecified, fontSize: TextUnit = TextUnit.Unspecified,
lineHeight: TextUnit = TextUnit.Unspecified, lineHeight: TextUnit = TextUnit.Unspecified,
onClick: () -> Unit) { contentColor: Color = MaterialTheme.colors.onBackground,
backgroundColor: Color = Color.Transparent,
onClick: () -> Unit
) {
TextButton( TextButton(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier,
shape = RoundedCornerShape(50), // = 50% percent shape = RoundedCornerShape(percent = 50),
colors = ButtonDefaults.outlinedButtonColors( colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colors.onBackground, contentColor = contentColor,
backgroundColor = MaterialTheme.colors.background backgroundColor = backgroundColor
) )
) { ) {
Text( Text(
@ -271,7 +297,8 @@ fun Modifier.contentDescription(id: Int?): Modifier {
@Composable @Composable
fun OutlineButton(text: GetString, contentDescription: GetString? = text, modifier: Modifier = Modifier, onClick: () -> Unit) { fun OutlineButton(text: GetString, contentDescription: GetString? = text, modifier: Modifier = Modifier, onClick: () -> Unit) {
OutlinedButton( OutlinedButton(
modifier = modifier.size(108.dp, 34.dp) modifier = modifier
.size(108.dp, 34.dp)
.contentDescription(contentDescription), .contentDescription(contentDescription),
onClick = onClick, onClick = onClick,
border = BorderStroke(1.dp, LocalExtraColors.current.prominentButtonColor), border = BorderStroke(1.dp, LocalExtraColors.current.prominentButtonColor),
@ -403,7 +430,8 @@ fun RowScope.SessionShieldIcon() {
Icon( Icon(
painter = painterResource(R.drawable.session_shield), painter = painterResource(R.drawable.session_shield),
contentDescription = null, contentDescription = null,
modifier = Modifier.align(Alignment.CenterVertically) modifier = Modifier
.align(Alignment.CenterVertically)
.wrapContentSize(unbounded = true) .wrapContentSize(unbounded = true)
) )
} }

View File

@ -48,7 +48,7 @@ sealed class GetString {
fun GetString(@StringRes resId: Int) = GetString.FromResId(resId) fun GetString(@StringRes resId: Int) = GetString.FromResId(resId)
fun GetString(string: String) = GetString.FromString(string) fun GetString(string: String) = GetString.FromString(string)
fun GetString(function: (Context) -> String) = GetString.FromFun(function) 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) 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_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="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="accountIdErrorInvalid">This Account ID is invalid. Please check and try again.</string>
<string name="enter_account_id">Enter Account ID</string>
</resources> </resources>

View File

@ -22,6 +22,7 @@ kotlinVersion=1.8.21
android.useAndroidX=true android.useAndroidX=true
appcompatVersion=1.6.1 appcompatVersion=1.6.1
coreVersion=1.8.0 coreVersion=1.8.0
composeVersion=1.6.4
coroutinesVersion=1.6.4 coroutinesVersion=1.6.4
curve25519Version=0.6.0 curve25519Version=0.6.0
daggerVersion=2.46.1 daggerVersion=2.46.1
@ -30,7 +31,7 @@ jacksonDatabindVersion=2.9.8
junitVersion=4.13.2 junitVersion=4.13.2
kotlinxJsonVersion=1.3.3 kotlinxJsonVersion=1.3.3
kovenantVersion=3.3.0 kovenantVersion=3.3.0
lifecycleVersion=2.5.1 lifecycleVersion=2.7.0
materialVersion=1.8.0 materialVersion=1.8.0
mockitoKotlinVersion=4.1.0 mockitoKotlinVersion=4.1.0
okhttpVersion=3.12.1 okhttpVersion=3.12.1