mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 21:15:48 +00:00
Add QR scanning to username link flow.
This commit is contained in:
committed by
Nicholas Tinsley
parent
bb8fdcabcb
commit
dc2e249566
@@ -559,6 +559,7 @@ dependencies {
|
||||
}
|
||||
implementation libs.dnsjava
|
||||
implementation libs.kotlinx.collections.immutable
|
||||
implementation libs.accompanist.permissions
|
||||
|
||||
spinnerImplementation project(":spinner")
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -93,12 +94,12 @@ private fun DrawScope.drawQr(
|
||||
}
|
||||
|
||||
// Logo border
|
||||
val deadzonePaddingPercent = 0.02f
|
||||
val deadzonePaddingPercent = 0.03f
|
||||
val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2
|
||||
drawCircle(
|
||||
color = foregroundColor,
|
||||
radius = logoBorderRadiusPx,
|
||||
style = Stroke(width = cellWidthPx * 0.7f),
|
||||
style = Stroke(width = 4.dp.toPx()),
|
||||
center = this.center
|
||||
)
|
||||
|
||||
@@ -156,9 +157,7 @@ private fun Preview() {
|
||||
Surface {
|
||||
QrCode(
|
||||
data = QrCodeData.forData("https://signal.org", 64),
|
||||
modifier = Modifier
|
||||
.width(100.dp)
|
||||
.height(100.dp),
|
||||
modifier = Modifier.size(200.dp),
|
||||
deadzonePercent = 0.3f
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
/**
|
||||
* Result of taking data from the QR scanner and trying to resolve it to a recipient.
|
||||
*/
|
||||
sealed class QrScanResult {
|
||||
class Success(val recipient: Recipient) : QrScanResult()
|
||||
|
||||
class NotFound(val username: String) : QrScanResult()
|
||||
|
||||
object InvalidData : QrScanResult()
|
||||
|
||||
object NetworkError : QrScanResult()
|
||||
}
|
||||
@@ -1,25 +1,61 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalPermissionsApi::class
|
||||
)
|
||||
class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
|
||||
val viewModel: UsernameLinkSettingsViewModel by viewModels()
|
||||
private val viewModel: UsernameLinkSettingsViewModel by viewModels()
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
@@ -29,23 +65,121 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
val navController: NavController by remember { mutableStateOf(findNavController()) }
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
topBar = { TopAppBarContent(state.activeTab) }
|
||||
) { contentPadding ->
|
||||
UsernameLinkShareScreen(
|
||||
state = state,
|
||||
snackbarHostState = snackbarHostState,
|
||||
scope = scope,
|
||||
contentPadding = contentPadding,
|
||||
navController = navController
|
||||
)
|
||||
|
||||
if (state.indeterminateProgress) {
|
||||
Dialogs.IndeterminateProgressDialog()
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.activeTab == ActiveTab.Code,
|
||||
enter = slideInHorizontally(initialOffsetX = { fullWidth -> -fullWidth }),
|
||||
exit = slideOutHorizontally(targetOffsetX = { fullWidth -> -fullWidth })
|
||||
) {
|
||||
UsernameLinkShareScreen(
|
||||
state = state,
|
||||
snackbarHostState = snackbarHostState,
|
||||
scope = scope,
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.activeTab == ActiveTab.Scan,
|
||||
enter = slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }),
|
||||
exit = slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth })
|
||||
) {
|
||||
UsernameQrScanScreen(
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
disposables = disposables.disposables,
|
||||
qrScanResult = state.qrScanResult,
|
||||
onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) },
|
||||
onQrResultHandled = { viewModel.onQrResultHandled() },
|
||||
modifier = Modifier.padding(contentPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.onResume()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopAppBarContent(activeTab: ActiveTab) {
|
||||
val cameraPermissionState: PermissionState = rememberPermissionState(permission = android.Manifest.permission.CAMERA)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TabButton(
|
||||
label = stringResource(R.string.UsernameLinkSettings_code_tab_name),
|
||||
active = activeTab == ActiveTab.Code,
|
||||
onClick = { viewModel.onTabSelected(ActiveTab.Code) },
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
TabButton(
|
||||
label = stringResource(R.string.UsernameLinkSettings_scan_tab_name),
|
||||
active = activeTab == ActiveTab.Scan,
|
||||
onClick = {
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
viewModel.onTabSelected(ActiveTab.Scan)
|
||||
} else {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabButton(label: String, active: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val colors = if (active) {
|
||||
ButtonDefaults.filledTonalButtonColors()
|
||||
} else {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = SignalTheme.colors.colorSurface2,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
Buttons.MediumTonal(
|
||||
onClick = onClick,
|
||||
modifier = modifier.defaultMinSize(minWidth = 100.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = colors
|
||||
) {
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun AppBarPreview() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
TopAppBarContent(activeTab = ActiveTab.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewAll() {
|
||||
|
||||
@@ -7,8 +7,15 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.Username
|
||||
* Represents the UI state of the [UsernameLinkSettingsFragment].
|
||||
*/
|
||||
data class UsernameLinkSettingsState(
|
||||
val activeTab: ActiveTab,
|
||||
val username: String,
|
||||
val usernameLink: String,
|
||||
val qrCodeData: QrCodeData?,
|
||||
val qrCodeColorScheme: UsernameQrCodeColorScheme
|
||||
)
|
||||
val qrCodeColorScheme: UsernameQrCodeColorScheme,
|
||||
val qrScanResult: QrScanResult? = null,
|
||||
val indeterminateProgress: Boolean = false
|
||||
) {
|
||||
enum class ActiveTab {
|
||||
Code, Scan
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,27 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.usernames.BaseUsernameException
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import java.io.IOException
|
||||
|
||||
class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
|
||||
private val TAG = Log.tag(UsernameLinkSettingsViewModel::class.java)
|
||||
|
||||
private val username: BehaviorSubject<String> = BehaviorSubject.createDefault(Recipient.self().username.get())
|
||||
|
||||
private val _state = mutableStateOf(
|
||||
UsernameLinkSettingsState(
|
||||
activeTab = ActiveTab.Code,
|
||||
username = username.value!!,
|
||||
usernameLink = UsernameUtil.generateLink(username.value!!),
|
||||
qrCodeData = null,
|
||||
@@ -54,6 +64,61 @@ class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
|
||||
fun onTabSelected(tab: ActiveTab) {
|
||||
_state.value = _state.value.copy(
|
||||
activeTab = tab
|
||||
)
|
||||
}
|
||||
|
||||
fun onQrCodeScanned(url: String) {
|
||||
_state.value = _state.value.copy(
|
||||
indeterminateProgress = true
|
||||
)
|
||||
|
||||
disposable += Single
|
||||
.fromCallable {
|
||||
val username: String? = UsernameUtil.parseLink(url)
|
||||
|
||||
if (username == null) {
|
||||
Log.w(TAG, "Failed to parse username from url")
|
||||
return@fromCallable QrScanResult.InvalidData
|
||||
}
|
||||
|
||||
return@fromCallable try {
|
||||
val hashed: String = UsernameUtil.hashUsernameToBase64(username)
|
||||
val aci: ACI = ApplicationDependencies.getSignalServiceAccountManager().getAciByUsernameHash(hashed)
|
||||
QrScanResult.Success(Recipient.externalUsername(aci, username))
|
||||
} catch (e: BaseUsernameException) {
|
||||
Log.w(TAG, "Invalid username", e)
|
||||
QrScanResult.InvalidData
|
||||
} catch (e: NonSuccessfulResponseCodeException) {
|
||||
Log.w(TAG, "Non-successful response during username resolution", e)
|
||||
if (e.code == 404) {
|
||||
QrScanResult.NotFound(username)
|
||||
} else {
|
||||
QrScanResult.NetworkError
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Network error during username resolution", e)
|
||||
QrScanResult.NetworkError
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { result ->
|
||||
_state.value = _state.value.copy(
|
||||
qrScanResult = result,
|
||||
indeterminateProgress = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onQrResultHandled() {
|
||||
_state.value = _state.value.copy(
|
||||
qrScanResult = null
|
||||
)
|
||||
}
|
||||
|
||||
private fun generateQrCodeData(url: String): Single<QrCodeData> {
|
||||
return Single.fromCallable {
|
||||
QrCodeData.forData(url, 64)
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -36,6 +35,7 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeBadge
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -49,12 +49,10 @@ fun UsernameLinkShareScreen(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
scope: CoroutineScope,
|
||||
navController: NavController,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp)
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(contentPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
QrCodeBadge(
|
||||
@@ -85,7 +83,8 @@ fun UsernameLinkShareScreen(
|
||||
text = stringResource(id = R.string.UsernameLinkSettings_qr_description),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 24.dp, bottom = 36.dp, start = 43.dp, end = 43.dp)
|
||||
modifier = Modifier.padding(top = 24.dp, bottom = 36.dp, start = 43.dp, end = 43.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Row(
|
||||
@@ -186,6 +185,7 @@ private fun ScreenPreviewDarkTheme() {
|
||||
private fun previewState(): UsernameLinkSettingsState {
|
||||
val link = UsernameUtil.generateLink("maya.45")
|
||||
return UsernameLinkSettingsState(
|
||||
activeTab = ActiveTab.Code,
|
||||
username = "maya.45",
|
||||
usernameLink = link,
|
||||
qrCodeData = QrCodeData.forData(link, 64),
|
||||
|
||||
@@ -1,14 +1,144 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.qr.QrScannerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
|
||||
/**
|
||||
* A screen that allows you to scan a QR code to start a chat.
|
||||
*/
|
||||
@Composable
|
||||
fun UsernameQrScanScreen(modifier: Modifier = Modifier) {
|
||||
// TODO
|
||||
Text(text = "QR Scanner Placeholder")
|
||||
fun UsernameQrScanScreen(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
disposables: CompositeDisposable,
|
||||
qrScanResult: QrScanResult?,
|
||||
onQrCodeScanned: (String) -> Unit,
|
||||
onQrResultHandled: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when (qrScanResult) {
|
||||
QrScanResult.InvalidData -> {
|
||||
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_invalid), onDismiss = onQrResultHandled)
|
||||
}
|
||||
QrScanResult.NetworkError -> {
|
||||
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_network_error), onDismiss = onQrResultHandled)
|
||||
}
|
||||
is QrScanResult.NotFound -> {
|
||||
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found, qrScanResult.username), onDismiss = onQrResultHandled)
|
||||
}
|
||||
is QrScanResult.Success -> {
|
||||
CommunicationActions.startConversation(LocalContext.current, qrScanResult.recipient, null)
|
||||
onQrResultHandled()
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
val view = QrScannerView(context)
|
||||
disposables += view.qrData.subscribe { data ->
|
||||
onQrCodeScanned(data)
|
||||
}
|
||||
view
|
||||
},
|
||||
update = { view ->
|
||||
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted())
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, true)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawQrCrosshair()
|
||||
}
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.UsernameLinkSettings_qr_scan_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QrScanResultDialog(message: String, onDismiss: () -> Unit) {
|
||||
Dialogs.SimpleMessageDialog(
|
||||
message = message,
|
||||
dismiss = stringResource(id = android.R.string.ok),
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawQrCrosshair() {
|
||||
val crosshairWidth: Float = size.minDimension * 0.6f
|
||||
val clearWidth: Float = crosshairWidth * 0.75f
|
||||
|
||||
// Draw a full white rounded rect...
|
||||
drawRoundRect(
|
||||
color = Color.White,
|
||||
topLeft = center - Offset(crosshairWidth / 2, crosshairWidth / 2),
|
||||
style = Stroke(width = 3.dp.toPx()),
|
||||
size = Size(crosshairWidth, crosshairWidth),
|
||||
cornerRadius = CornerRadius(10.dp.toPx(), 10.dp.toPx())
|
||||
)
|
||||
|
||||
// ...then cut out the middle parts with BlendMode.Clear to leave us with just the corners
|
||||
drawRect(
|
||||
color = Color.White,
|
||||
topLeft = Offset(center.x - clearWidth / 2, 0f),
|
||||
style = Fill,
|
||||
size = Size(clearWidth, size.height),
|
||||
blendMode = BlendMode.Clear
|
||||
)
|
||||
|
||||
drawRect(
|
||||
color = Color.White,
|
||||
topLeft = Offset(0f, center.y - clearWidth / 2),
|
||||
style = Fill,
|
||||
size = Size(size.width, clearWidth),
|
||||
blendMode = BlendMode.Clear
|
||||
)
|
||||
}
|
||||
|
||||
@@ -247,14 +247,7 @@ public class ManageProfileFragment extends LoggingFragment {
|
||||
binding.manageProfileUsernameShare.setVisibility(View.GONE);
|
||||
} else {
|
||||
binding.manageProfileUsername.setText(username);
|
||||
|
||||
try {
|
||||
binding.manageProfileUsernameSubtitle.setText(UsernameUtil.generateLink(username));
|
||||
} catch (BaseUsernameException e) {
|
||||
Log.w(TAG, "Could not format username link", e);
|
||||
binding.manageProfileUsernameSubtitle.setText(R.string.ManageProfileFragment_your_username);
|
||||
}
|
||||
|
||||
binding.manageProfileUsernameSubtitle.setText(UsernameUtil.generateLink(username));
|
||||
binding.manageProfileUsernameShare.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ public final class Base64 {
|
||||
return org.whispersystems.util.Base64.decode(s);
|
||||
}
|
||||
|
||||
public static @NonNull byte[] decodeWithoutPadding(@NonNull String s) throws IOException {
|
||||
return org.whispersystems.util.Base64.decodeWithoutPadding(s);
|
||||
}
|
||||
|
||||
public static @NonNull String encodeBytes(@NonNull byte[] source) {
|
||||
return org.whispersystems.util.Base64.encodeBytes(source);
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ import org.whispersystems.signalservice.api.push.ServiceId;
|
||||
import org.whispersystems.util.Base64UrlSafe;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class UsernameUtil {
|
||||
@@ -31,6 +33,7 @@ public class UsernameUtil {
|
||||
|
||||
private static final Pattern FULL_PATTERN = Pattern.compile(String.format(Locale.US, "^[a-zA-Z_][a-zA-Z0-9_]{%d,%d}$", MIN_LENGTH - 1, MAX_LENGTH - 1), Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern DIGIT_START_PATTERN = Pattern.compile("^[0-9].*$");
|
||||
private static final Pattern URL_PATTERN = Pattern.compile("(https://)?signal.me/#u/([a-zA-Z0-9+/]*={0,2})");
|
||||
|
||||
|
||||
private static final String BASE_URL_SCHEMELESS = "signal.me/#u/";
|
||||
@@ -80,6 +83,14 @@ public class UsernameUtil {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes a username to a url-safe base64 string.
|
||||
* @throws BaseUsernameException If the username is invalid and un-hashable.
|
||||
*/
|
||||
public static String hashUsernameToBase64(String username) throws BaseUsernameException {
|
||||
return Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static @NonNull Optional<ServiceId> fetchAciForUsernameHash(@NonNull String base64UrlSafeEncodedUsernameHash) {
|
||||
try {
|
||||
@@ -91,13 +102,33 @@ public class UsernameUtil {
|
||||
}
|
||||
}
|
||||
|
||||
public static String generateLink(String username) throws BaseUsernameException {
|
||||
byte[] hash = Username.hash(username);
|
||||
String base64 = Base64UrlSafe.encodeBytesWithoutPadding(hash);
|
||||
public static String generateLink(String username) {
|
||||
String base64 = Base64UrlSafe.encodeBytesWithoutPadding(username.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
return BASE_URL + base64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the username from a link if possible, otherwise null.
|
||||
*/
|
||||
public static @Nullable String parseLink(String url) {
|
||||
Matcher matcher = URL_PATTERN.matcher(url);
|
||||
if (!matcher.matches()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String base64 = matcher.group(2);
|
||||
if (base64 == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new String(Base64.decodeWithoutPadding(base64));
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public enum InvalidReason {
|
||||
TOO_SHORT, TOO_LONG, INVALID_CHARACTERS, STARTS_WITH_NUMBER
|
||||
}
|
||||
|
||||
@@ -6095,6 +6095,12 @@
|
||||
<string name="UsernameLinkSettings_qr_scan_description">Scan the QR Code on your contact’s device.</string>
|
||||
<!-- App bar title for the username QR code color picker screen -->
|
||||
<string name="UsernameLinkSettings_color_picker_app_bar_title">Color</string>
|
||||
<!-- Body of a dialog that is displayed when we failed to read a username QR code. -->
|
||||
<string name="UsernameLinkSettings_qr_result_invalid">The QR code was invalid.</string>
|
||||
<!-- Body of a dialog that is displayed when the username we looked up could not be found. -->
|
||||
<string name="UsernameLinkSettings_qr_result_not_found">A user with username $1$s could not be found.</string>
|
||||
<!-- Body of a dialog that is displayed when we experienced a network error when looking up a username. -->
|
||||
<string name="UsernameLinkSettings_qr_result_network_error">Experienced a network error. Please try again.</string>
|
||||
|
||||
<!-- EOF -->
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user