Merge branch 'dla/feature/custom_room_notification_settings_list' of https://github.com/vector-im/element-x-android into dla/feature/custom_room_notification_settings_list
This commit is contained in:
@@ -8,8 +8,6 @@ appId: ${APP_ID}
|
||||
# Back from timeline
|
||||
- back
|
||||
- assertVisible: "MyR"
|
||||
# Close keyboard
|
||||
- hideKeyboard
|
||||
# Back from search
|
||||
- back
|
||||
- runFlow: ../assertions/assertHomeDisplayed.yaml
|
||||
|
||||
24
appconfig/build.gradle.kts
Normal file
24
appconfig/build.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
plugins {
|
||||
id("java-library")
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.appconfig
|
||||
|
||||
object AuthenticationConfig {
|
||||
const val MATRIX_ORG_URL = "https://matrix.org"
|
||||
|
||||
const val DEFAULT_HOMESERVER_URL = MATRIX_ORG_URL
|
||||
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
|
||||
}
|
||||
@@ -14,8 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.services.toolbox.api.appname
|
||||
package io.element.android.appconfig
|
||||
|
||||
interface AppNameProvider {
|
||||
fun getAppName(): String
|
||||
object ElementCallConfig {
|
||||
const val DEFAULT_BASE_URL = "https://call.element.io"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.appconfig
|
||||
|
||||
object LockScreenConfig {
|
||||
|
||||
/**
|
||||
* Whether the PIN is mandatory or not.
|
||||
*/
|
||||
const val IS_PIN_MANDATORY: Boolean = false
|
||||
|
||||
/**
|
||||
* Some PINs are blacklisted.
|
||||
*/
|
||||
val PIN_BLACKLIST = setOf("0000", "1234")
|
||||
|
||||
/**
|
||||
* The size of the PIN.
|
||||
*/
|
||||
const val PIN_SIZE = 4
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.config
|
||||
package io.element.android.appconfig
|
||||
|
||||
object MatrixConfiguration {
|
||||
const val matrixToPermalinkBaseUrl: String = "https://matrix.to/#/"
|
||||
@@ -198,6 +198,8 @@ koverMerged {
|
||||
// We do not cover Nodes (normally covered by maestro, but code coverage is not computed with maestro)
|
||||
"*Node",
|
||||
"*Node$*",
|
||||
// Exclude `:libraries:matrix:impl` module, it contains only wrappers to access the Rust Matrix SDK api, so it is not really relevant to unit test it: there is no logic to test.
|
||||
"io.element.android.libraries.matrix.impl.*",
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -250,10 +252,6 @@ koverMerged {
|
||||
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
|
||||
// Some options can't be tested at the moment
|
||||
excludes += "io.element.android.features.preferences.impl.developer.DeveloperSettingsPresenter$*"
|
||||
// Temporary until we have actually something to test.
|
||||
excludes += "io.element.android.features.lockscreen.impl.auth.PinAuthenticationPresenter"
|
||||
excludes += "io.element.android.features.lockscreen.impl.auth.PinAuthenticationPresenter$*"
|
||||
excludes += "io.element.android.features.lockscreen.impl.create.CreatePinPresenter"
|
||||
}
|
||||
bound {
|
||||
minValue = 85
|
||||
|
||||
1
changelog.d/1596.feature
Normal file
1
changelog.d/1596.feature
Normal file
@@ -0,0 +1 @@
|
||||
Record and send voice messages
|
||||
1
changelog.d/1617.bugfix
Normal file
1
changelog.d/1617.bugfix
Normal file
@@ -0,0 +1 @@
|
||||
Fix issue where text is cleared when cancelling a reply
|
||||
@@ -18,20 +18,44 @@ plugins {
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.ksp)
|
||||
id("kotlin-parcelize")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.call"
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
anvil {
|
||||
generateDaggerFactories.set(true)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.appnav)
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.anvilannotations)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.matrix.impl)
|
||||
implementation(projects.libraries.network)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.services.toolbox.api)
|
||||
implementation(libs.androidx.webkit)
|
||||
implementation(libs.serialization.json)
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name=".ElementCallActivity"
|
||||
android:name=".ui.ElementCallActivity"
|
||||
android:label="@string/element_call"
|
||||
android:exported="true"
|
||||
android:taskAffinity="io.element.android.features.call"
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import io.element.android.features.call.ui.ElementCallActivity
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
|
||||
class CallForegroundService : Service() {
|
||||
|
||||
@@ -14,25 +14,21 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.create
|
||||
package io.element.android.features.call
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import javax.inject.Inject
|
||||
import android.os.Parcelable
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
class CreatePinPresenter @Inject constructor() : Presenter<CreatePinState> {
|
||||
sealed interface CallType : NodeInputs, Parcelable {
|
||||
@Parcelize
|
||||
data class ExternalUrl(val url: String) : CallType
|
||||
|
||||
@Composable
|
||||
override fun present(): CreatePinState {
|
||||
|
||||
fun handleEvents(event: CreatePinEvents) {
|
||||
when (event) {
|
||||
CreatePinEvents.MyEvent -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
return CreatePinState(
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
@Parcelize
|
||||
data class RoomCall(
|
||||
val sessionId: SessionId,
|
||||
val roomId: RoomId,
|
||||
) : CallType
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class WidgetMessage(
|
||||
@SerialName("api") val direction: Direction,
|
||||
@SerialName("widgetId") val widgetId: String,
|
||||
@SerialName("requestId") val requestId: String,
|
||||
@SerialName("action") val action: Action,
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
enum class Direction {
|
||||
@SerialName("fromWidget")
|
||||
FromWidget,
|
||||
@SerialName("toWidget")
|
||||
ToWidget
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class Action {
|
||||
@SerialName("im.vector.hangup")
|
||||
HangUp
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
package io.element.android.features.call.di
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import io.element.android.features.call.ElementCallActivity
|
||||
import io.element.android.features.call.ui.ElementCallActivity
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
||||
@ContributesTo(AppScope::class)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.ui
|
||||
|
||||
import io.element.android.features.call.utils.WidgetMessageInterceptor
|
||||
|
||||
sealed interface CallScreeEvents {
|
||||
data object Hangup : CallScreeEvents
|
||||
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreeEvents
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.call.CallType
|
||||
import io.element.android.features.call.data.WidgetMessage
|
||||
import io.element.android.features.call.utils.CallWidgetProvider
|
||||
import io.element.android.features.call.utils.WidgetMessageInterceptor
|
||||
import io.element.android.features.call.utils.WidgetMessageSerializer
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
import io.element.android.libraries.network.useragent.UserAgentProvider
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
class CallScreenPresenter @AssistedInject constructor(
|
||||
@Assisted private val callType: CallType,
|
||||
@Assisted private val navigator: CallScreenNavigator,
|
||||
private val callWidgetProvider: CallWidgetProvider,
|
||||
private val userAgentProvider: UserAgentProvider,
|
||||
private val clock: SystemClock,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : Presenter<CallScreenState> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(callType: CallType, navigator: CallScreenNavigator): CallScreenPresenter
|
||||
}
|
||||
|
||||
private val isInWidgetMode = callType is CallType.RoomCall
|
||||
private val userAgent = userAgentProvider.provide()
|
||||
|
||||
@Composable
|
||||
override fun present(): CallScreenState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val urlState = remember { mutableStateOf<Async<String>>(Async.Uninitialized) }
|
||||
val callWidgetDriver = remember { mutableStateOf<MatrixWidgetDriver?>(null) }
|
||||
val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
loadUrl(callType, urlState, callWidgetDriver)
|
||||
}
|
||||
|
||||
callWidgetDriver.value?.let { driver ->
|
||||
LaunchedEffect(Unit) {
|
||||
driver.incomingMessages
|
||||
.onEach {
|
||||
// Relay message to the WebView
|
||||
messageInterceptor.value?.sendMessage(it)
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
driver.run()
|
||||
}
|
||||
}
|
||||
|
||||
messageInterceptor.value?.let { interceptor ->
|
||||
LaunchedEffect(Unit) {
|
||||
interceptor.interceptedMessages
|
||||
.onEach {
|
||||
// Relay message to Widget Driver
|
||||
callWidgetDriver.value?.send(it)
|
||||
|
||||
val parsedMessage = parseMessage(it)
|
||||
if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget && parsedMessage.action == WidgetMessage.Action.HangUp) {
|
||||
close(callWidgetDriver.value, navigator)
|
||||
}
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvents(event: CallScreeEvents) {
|
||||
when (event) {
|
||||
is CallScreeEvents.Hangup -> {
|
||||
val widgetId = callWidgetDriver.value?.id
|
||||
val interceptor = messageInterceptor.value
|
||||
if (widgetId != null && interceptor != null) {
|
||||
sendHangupMessage(widgetId, interceptor)
|
||||
}
|
||||
coroutineScope.launch {
|
||||
close(callWidgetDriver.value, navigator)
|
||||
}
|
||||
}
|
||||
is CallScreeEvents.SetupMessageChannels -> {
|
||||
messageInterceptor.value = event.widgetMessageInterceptor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CallScreenState(
|
||||
urlState = urlState.value,
|
||||
userAgent = userAgent,
|
||||
isInWidgetMode = isInWidgetMode,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.loadUrl(
|
||||
inputs: CallType,
|
||||
urlState: MutableState<Async<String>>,
|
||||
callWidgetDriver: MutableState<MatrixWidgetDriver?>,
|
||||
) = launch {
|
||||
urlState.runCatchingUpdatingState {
|
||||
when (inputs) {
|
||||
is CallType.ExternalUrl -> {
|
||||
inputs.url
|
||||
}
|
||||
is CallType.RoomCall -> {
|
||||
val (driver, url) = callWidgetProvider.getWidget(
|
||||
sessionId = inputs.sessionId,
|
||||
roomId = inputs.roomId,
|
||||
clientId = UUID.randomUUID().toString(),
|
||||
).getOrThrow()
|
||||
callWidgetDriver.value = driver
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseMessage(message: String): WidgetMessage? {
|
||||
return WidgetMessageSerializer.deserialize(message).getOrNull()
|
||||
}
|
||||
|
||||
private fun sendHangupMessage(widgetId: String, messageInterceptor: WidgetMessageInterceptor) {
|
||||
val message = WidgetMessage(
|
||||
direction = WidgetMessage.Direction.ToWidget,
|
||||
widgetId = widgetId,
|
||||
requestId = "widgetapi-${clock.epochMillis()}",
|
||||
action = WidgetMessage.Action.HangUp,
|
||||
)
|
||||
messageInterceptor.sendMessage(WidgetMessageSerializer.serialize(message))
|
||||
}
|
||||
|
||||
private fun CoroutineScope.close(widgetDriver: MatrixWidgetDriver?, navigator: CallScreenNavigator) = launch(dispatchers.io) {
|
||||
navigator.close()
|
||||
widgetDriver?.close()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.ui
|
||||
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
||||
data class CallScreenState(
|
||||
val urlState: Async<String>,
|
||||
val userAgent: String,
|
||||
val isInWidgetMode: Boolean,
|
||||
val eventSink: (CallScreeEvents) -> Unit,
|
||||
)
|
||||
@@ -14,106 +14,128 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call
|
||||
package io.element.android.features.call.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.PermissionRequest
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import io.element.android.features.call.R
|
||||
import io.element.android.features.call.utils.WebViewWidgetMessageInterceptor
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
typealias RequestPermissionCallback = (Array<String>) -> Unit
|
||||
|
||||
interface CallScreenNavigator {
|
||||
fun close()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun CallScreenView(
|
||||
url: String?,
|
||||
userAgent: String,
|
||||
state: CallScreenState,
|
||||
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ElementTheme {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.element_call)) },
|
||||
navigationIcon = {
|
||||
BackButton(
|
||||
resourceId = CommonDrawables.ic_compound_close,
|
||||
onClick = onClose
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
CallWebView(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.fillMaxSize(),
|
||||
url = url,
|
||||
userAgent = userAgent,
|
||||
onPermissionsRequested = { request ->
|
||||
val androidPermissions = mapWebkitPermissions(request.resources)
|
||||
val callback: RequestPermissionCallback = { request.grant(it) }
|
||||
requestPermissions(androidPermissions.toTypedArray(), callback)
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.element_call)) },
|
||||
navigationIcon = {
|
||||
BackButton(
|
||||
resourceId = CommonDrawables.ic_compound_close,
|
||||
onClick = { state.eventSink(CallScreeEvents.Hangup) }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
BackHandler {
|
||||
state.eventSink(CallScreeEvents.Hangup)
|
||||
}
|
||||
CallWebView(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.fillMaxSize(),
|
||||
url = state.urlState,
|
||||
userAgent = state.userAgent,
|
||||
onPermissionsRequested = { request ->
|
||||
val androidPermissions = mapWebkitPermissions(request.resources)
|
||||
val callback: RequestPermissionCallback = { request.grant(it) }
|
||||
requestPermissions(androidPermissions.toTypedArray(), callback)
|
||||
},
|
||||
onWebViewCreated = { webView ->
|
||||
val interceptor = WebViewWidgetMessageInterceptor(webView)
|
||||
state.eventSink(CallScreeEvents.SetupMessageChannels(interceptor))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallWebView(
|
||||
url: String?,
|
||||
url: Async<String>,
|
||||
userAgent: String,
|
||||
onPermissionsRequested: (PermissionRequest) -> Unit,
|
||||
onWebViewCreated: (WebView) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isInpectionMode = LocalInspectionMode.current
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
if (!isInpectionMode) {
|
||||
setup(userAgent, onPermissionsRequested)
|
||||
if (url != null) {
|
||||
loadUrl(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
if (!isInpectionMode && url != null) {
|
||||
webView.loadUrl(url)
|
||||
}
|
||||
},
|
||||
onRelease = { webView ->
|
||||
webView.destroy()
|
||||
if (LocalInspectionMode.current) {
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
Text("WebView - can't be previewed")
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
setup(userAgent, onPermissionsRequested)
|
||||
if (url is Async.Success) {
|
||||
loadUrl(url.data)
|
||||
}
|
||||
|
||||
onWebViewCreated(this)
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
if (url is Async.Success && webView.url != url.data) {
|
||||
webView.loadUrl(url.data)
|
||||
}
|
||||
},
|
||||
onRelease = { webView ->
|
||||
webView.destroy()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun WebView.setup(userAgent: String, onPermissionsRequested: (PermissionRequest) -> Unit) {
|
||||
private fun WebView.setup(
|
||||
userAgent: String,
|
||||
onPermissionsRequested: (PermissionRequest) -> Unit,
|
||||
) {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
@@ -140,12 +162,15 @@ private fun WebView.setup(userAgent: String, onPermissionsRequested: (Permission
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun CallScreenViewPreview() {
|
||||
ElementTheme {
|
||||
ElementPreview {
|
||||
CallScreenView(
|
||||
url = "https://call.element.io/some-actual-call?with=parameters",
|
||||
userAgent = "",
|
||||
state = CallScreenState(
|
||||
urlState = Async.Success("https://call.element.io/some-actual-call?with=parameters"),
|
||||
isInWidgetMode = false,
|
||||
userAgent = "",
|
||||
eventSink = {},
|
||||
),
|
||||
requestPermissions = { _, _ -> },
|
||||
onClose = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call
|
||||
package io.element.android.features.call.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.res.Configuration
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
@@ -26,20 +28,40 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import android.webkit.PermissionRequest
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.core.content.IntentCompat
|
||||
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
|
||||
import io.element.android.features.call.CallForegroundService
|
||||
import io.element.android.features.call.CallType
|
||||
import io.element.android.features.call.di.CallBindings
|
||||
import io.element.android.features.call.utils.CallIntentDataParser
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.network.useragent.UserAgentProvider
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import javax.inject.Inject
|
||||
|
||||
class ElementCallActivity : ComponentActivity() {
|
||||
class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator {
|
||||
companion object {
|
||||
private const val EXTRA_CALL_WIDGET_SETTINGS = "EXTRA_CALL_WIDGET_SETTINGS"
|
||||
|
||||
fun start(
|
||||
context: Context,
|
||||
callInputs: CallType,
|
||||
) {
|
||||
val intent = Intent(context, ElementCallActivity::class.java).apply {
|
||||
putExtra(EXTRA_CALL_WIDGET_SETTINGS, callInputs)
|
||||
addFlags(FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@Inject lateinit var userAgentProvider: UserAgentProvider
|
||||
@Inject lateinit var callIntentDataParser: CallIntentDataParser
|
||||
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
|
||||
|
||||
private lateinit var presenter: CallScreenPresenter
|
||||
|
||||
private lateinit var audioManager: AudioManager
|
||||
|
||||
@@ -51,7 +73,7 @@ class ElementCallActivity : ComponentActivity() {
|
||||
private val requestPermissionsLauncher = registerPermissionResultLauncher()
|
||||
|
||||
private var isDarkMode = false
|
||||
private val urlState = mutableStateOf<String?>(null)
|
||||
private val webViewTarget = mutableStateOf<CallType?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -60,10 +82,7 @@ class ElementCallActivity : ComponentActivity() {
|
||||
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
urlState.value = intent?.dataString?.let(::parseUrl) ?: run {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
setCallType(intent)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
updateUiMode(resources.configuration)
|
||||
@@ -72,18 +91,17 @@ class ElementCallActivity : ComponentActivity() {
|
||||
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
||||
requestAudioFocus()
|
||||
|
||||
val userAgent = userAgentProvider.provide()
|
||||
|
||||
setContent {
|
||||
CallScreenView(
|
||||
url = urlState.value,
|
||||
userAgent = userAgent,
|
||||
onClose = this::finish,
|
||||
requestPermissions = { permissions, callback ->
|
||||
requestPermissionCallback = callback
|
||||
requestPermissionsLauncher.launch(permissions)
|
||||
}
|
||||
)
|
||||
val state = presenter.present()
|
||||
ElementTheme {
|
||||
CallScreenView(
|
||||
state = state,
|
||||
requestPermissions = { permissions, callback ->
|
||||
requestPermissionCallback = callback
|
||||
requestPermissionsLauncher.launch(permissions)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,15 +114,7 @@ class ElementCallActivity : ComponentActivity() {
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
|
||||
val intentUrl = intent?.dataString?.let(::parseUrl)
|
||||
when {
|
||||
// New URL, update it and reload the webview
|
||||
intentUrl != null -> urlState.value = intentUrl
|
||||
// Re-opened the activity but we have no url to load or a cached one, finish the activity
|
||||
intent?.dataString == null && urlState.value == null -> finish()
|
||||
// Coming back from notification, do nothing
|
||||
else -> return
|
||||
}
|
||||
setCallType(intent)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
@@ -130,6 +140,32 @@ class ElementCallActivity : ComponentActivity() {
|
||||
finishAndRemoveTask()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun setCallType(intent: Intent?) {
|
||||
val inputs = intent?.let {
|
||||
IntentCompat.getParcelableExtra(it, EXTRA_CALL_WIDGET_SETTINGS, CallType::class.java)
|
||||
}
|
||||
val intentUrl = intent?.dataString?.let(::parseUrl)
|
||||
when {
|
||||
// Re-opened the activity but we have no url to load or a cached one, finish the activity
|
||||
intent?.dataString == null && inputs == null && webViewTarget.value == null -> finish()
|
||||
inputs != null -> {
|
||||
webViewTarget.value = inputs
|
||||
presenter = presenterFactory.create(inputs, this)
|
||||
}
|
||||
intentUrl != null -> {
|
||||
val fallbackInputs = CallType.ExternalUrl(intentUrl)
|
||||
webViewTarget.value = fallbackInputs
|
||||
presenter = presenterFactory.create(fallbackInputs, this)
|
||||
}
|
||||
// Coming back from notification, do nothing
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseUrl(url: String?): String? = callIntentDataParser.parse(url)
|
||||
|
||||
private fun registerPermissionResultLauncher(): ActivityResultLauncher<Array<String>> {
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call
|
||||
package io.element.android.features.call.utils
|
||||
|
||||
import android.net.Uri
|
||||
import javax.inject.Inject
|
||||
@@ -14,17 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.auth
|
||||
package io.element.android.features.call.utils
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
|
||||
open class PinAuthenticationStateProvider : PreviewParameterProvider<PinAuthenticationState> {
|
||||
override val values: Sequence<PinAuthenticationState>
|
||||
get() = sequenceOf(
|
||||
aPinAuthenticationState(),
|
||||
)
|
||||
interface CallWidgetProvider {
|
||||
suspend fun getWidget(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId,
|
||||
clientId: String,
|
||||
languageTag: String? = null,
|
||||
theme: String? = null,
|
||||
): Result<Pair<MatrixWidgetDriver, String>>
|
||||
}
|
||||
|
||||
fun aPinAuthenticationState() = PinAuthenticationState(
|
||||
eventSink = {}
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.utils
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.appconfig.ElementCallConfig
|
||||
import io.element.android.features.preferences.api.store.PreferencesStore
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultCallWidgetProvider @Inject constructor(
|
||||
private val matrixClientsProvider: MatrixClientProvider,
|
||||
private val preferencesStore: PreferencesStore,
|
||||
private val callWidgetSettingsProvider: CallWidgetSettingsProvider,
|
||||
) : CallWidgetProvider {
|
||||
override suspend fun getWidget(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId,
|
||||
clientId: String,
|
||||
languageTag: String?,
|
||||
theme: String?,
|
||||
): Result<Pair<MatrixWidgetDriver, String>> = runCatching {
|
||||
val room = matrixClientsProvider.getOrRestore(sessionId).getOrThrow().getRoom(roomId) ?: error("Room not found")
|
||||
val baseUrl = preferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull() ?: ElementCallConfig.DEFAULT_BASE_URL
|
||||
val widgetSettings = callWidgetSettingsProvider.provide(baseUrl)
|
||||
val callUrl = room.generateWidgetWebViewUrl(widgetSettings, clientId, languageTag, theme).getOrThrow()
|
||||
room.getWidgetDriver(widgetSettings).getOrThrow() to callUrl
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.utils
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import io.element.android.features.call.BuildConfig
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
||||
class WebViewWidgetMessageInterceptor(
|
||||
private val webView: WebView,
|
||||
) : WidgetMessageInterceptor {
|
||||
|
||||
companion object {
|
||||
// We call both the WebMessageListener and the JavascriptInterface objects in JS with this
|
||||
// 'listenerName' so they can both receive the data from the WebView when
|
||||
// `${LISTENER_NAME}.postMessage(...)` is called
|
||||
const val LISTENER_NAME = "elementX"
|
||||
}
|
||||
|
||||
override val interceptedMessages = MutableSharedFlow<String>(replay = 1, extraBufferCapacity = 2)
|
||||
|
||||
init {
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
|
||||
// We inject this JS code when the page starts loading to attach a message listener to the window.
|
||||
// This listener will receive both messages:
|
||||
// - EC widget API -> Element X (message.data.api == "fromWidget")
|
||||
// - Element X -> EC widget API (message.data.api == "toWidget"), we should ignore these
|
||||
view?.evaluateJavascript(
|
||||
"""
|
||||
window.addEventListener('message', function(event) {
|
||||
let message = {data: event.data, origin: event.origin}
|
||||
if (message.data.response && message.data.api == "toWidget"
|
||||
|| !message.data.response && message.data.api == "fromWidget") {
|
||||
let json = JSON.stringify(event.data)
|
||||
${"console.log('message sent: ' + json);".takeIf { BuildConfig.DEBUG } }
|
||||
${LISTENER_NAME}.postMessage(json);
|
||||
} else {
|
||||
${"console.log('message received (ignored): ' + JSON.stringify(event.data));".takeIf { BuildConfig.DEBUG } }
|
||||
}
|
||||
});
|
||||
""".trimIndent(),
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a WebMessageListener, which will receive messages from the WebView and reply to them
|
||||
val webMessageListener = WebViewCompat.WebMessageListener { _, message, _, _, _ ->
|
||||
onMessageReceived(message.data)
|
||||
}
|
||||
|
||||
// Use WebMessageListener if supported, otherwise use JavascriptInterface
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
|
||||
WebViewCompat.addWebMessageListener(
|
||||
webView,
|
||||
LISTENER_NAME,
|
||||
setOf("*"),
|
||||
webMessageListener
|
||||
)
|
||||
} else {
|
||||
webView.addJavascriptInterface(object {
|
||||
@JavascriptInterface
|
||||
fun postMessage(json: String?) {
|
||||
onMessageReceived(json)
|
||||
}
|
||||
}, LISTENER_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendMessage(message: String) {
|
||||
webView.evaluateJavascript("postMessage($message, '*')", null)
|
||||
}
|
||||
|
||||
private fun onMessageReceived(json: String?) {
|
||||
// Here is where we would handle the messages from the WebView, passing them to the Rust SDK
|
||||
json?.let { interceptedMessages.tryEmit(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.utils
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface WidgetMessageInterceptor {
|
||||
val interceptedMessages: Flow<String>
|
||||
fun sendMessage(message: String)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.utils
|
||||
|
||||
import io.element.android.features.call.data.WidgetMessage
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object WidgetMessageSerializer {
|
||||
|
||||
private val coder = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun deserialize(message: String): Result<WidgetMessage> {
|
||||
return runCatching { coder.decodeFromString(WidgetMessage.serializer(), message) }
|
||||
}
|
||||
|
||||
fun serialize(message: WidgetMessage): String {
|
||||
return coder.encodeToString(WidgetMessage.serializer(), message)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ package io.element.android.features.call
|
||||
import android.Manifest
|
||||
import android.webkit.PermissionRequest
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.call.ui.mapWebkitPermissions
|
||||
import org.junit.Test
|
||||
|
||||
class MapWebkitPermissionsTest {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.ui
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.call.CallType
|
||||
import io.element.android.features.call.utils.FakeCallWidgetProvider
|
||||
import io.element.android.features.call.utils.FakeWidgetMessageInterceptor
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
|
||||
import io.element.android.libraries.network.useragent.UserAgentProvider
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class CallScreenPresenterTest {
|
||||
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - with CallType ExternalUrl just loads the URL`() = runTest {
|
||||
val presenter = createCallScreenPresenter(CallType.ExternalUrl("https://call.element.io"))
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
// Wait until the URL is loaded
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.urlState).isEqualTo(Async.Success("https://call.element.io"))
|
||||
assertThat(initialState.isInWidgetMode).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - with CallType RoomCall loads URL and runs WidgetDriver`() = runTest {
|
||||
val widgetDriver = FakeWidgetDriver()
|
||||
val widgetProvider = FakeCallWidgetProvider(widgetDriver)
|
||||
val presenter = createCallScreenPresenter(
|
||||
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
|
||||
widgetDriver = widgetDriver,
|
||||
widgetProvider = widgetProvider,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
// Wait until the URL is loaded
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.urlState).isInstanceOf(Async.Success::class.java)
|
||||
assertThat(initialState.isInWidgetMode).isTrue()
|
||||
assertThat(widgetProvider.getWidgetCalled).isTrue()
|
||||
assertThat(widgetDriver.runCalledCount).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - set message interceptor, send and receive messages`() = runTest {
|
||||
val widgetDriver = FakeWidgetDriver()
|
||||
val presenter = createCallScreenPresenter(
|
||||
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
|
||||
widgetDriver = widgetDriver,
|
||||
)
|
||||
val messageInterceptor = FakeWidgetMessageInterceptor()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(CallScreeEvents.SetupMessageChannels(messageInterceptor))
|
||||
|
||||
// And incoming message from the Widget Driver is passed to the WebView
|
||||
widgetDriver.givenIncomingMessage("A message")
|
||||
assertThat(messageInterceptor.sentMessages).containsExactly("A message")
|
||||
|
||||
// And incoming message from the WebView is passed to the Widget Driver
|
||||
messageInterceptor.givenInterceptedMessage("A reply")
|
||||
assertThat(widgetDriver.sentMessages).containsExactly("A reply")
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val navigator = FakeCallScreenNavigator()
|
||||
val widgetDriver = FakeWidgetDriver()
|
||||
val presenter = createCallScreenPresenter(
|
||||
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
|
||||
widgetDriver = widgetDriver,
|
||||
navigator = navigator,
|
||||
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||
)
|
||||
val messageInterceptor = FakeWidgetMessageInterceptor()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(CallScreeEvents.SetupMessageChannels(messageInterceptor))
|
||||
|
||||
initialState.eventSink(CallScreeEvents.Hangup)
|
||||
|
||||
// Let background coroutines run
|
||||
runCurrent()
|
||||
|
||||
assertThat(navigator.closeCalled).isTrue()
|
||||
assertThat(widgetDriver.closeCalledCount).isEqualTo(1)
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - a received hang up message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val navigator = FakeCallScreenNavigator()
|
||||
val widgetDriver = FakeWidgetDriver()
|
||||
val presenter = createCallScreenPresenter(
|
||||
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
|
||||
widgetDriver = widgetDriver,
|
||||
navigator = navigator,
|
||||
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||
)
|
||||
val messageInterceptor = FakeWidgetMessageInterceptor()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(CallScreeEvents.SetupMessageChannels(messageInterceptor))
|
||||
|
||||
messageInterceptor.givenInterceptedMessage("""{"action":"im.vector.hangup","api":"fromWidget","widgetId":"1","requestId":"1"}""")
|
||||
|
||||
// Let background coroutines run
|
||||
runCurrent()
|
||||
|
||||
assertThat(navigator.closeCalled).isTrue()
|
||||
assertThat(widgetDriver.closeCalledCount).isEqualTo(1)
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createCallScreenPresenter(
|
||||
callType: CallType,
|
||||
navigator: CallScreenNavigator = FakeCallScreenNavigator(),
|
||||
widgetDriver: FakeWidgetDriver = FakeWidgetDriver(),
|
||||
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
|
||||
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
): CallScreenPresenter {
|
||||
val userAgentProvider = object : UserAgentProvider {
|
||||
override fun provide(): String {
|
||||
return "Test"
|
||||
}
|
||||
}
|
||||
val clock = SystemClock { 0 }
|
||||
return CallScreenPresenter(
|
||||
callType,
|
||||
navigator,
|
||||
widgetProvider,
|
||||
userAgentProvider,
|
||||
clock,
|
||||
dispatchers,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.auth
|
||||
package io.element.android.features.call.ui
|
||||
|
||||
data class PinAuthenticationState(
|
||||
val eventSink: (PinAuthenticationEvents) -> Unit
|
||||
)
|
||||
class FakeCallScreenNavigator : CallScreenNavigator {
|
||||
var closeCalled = false
|
||||
private set
|
||||
|
||||
override fun close() {
|
||||
closeCalled = true
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call
|
||||
package io.element.android.features.call.utils
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.utils
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.preferences.api.store.PreferencesStore
|
||||
import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider
|
||||
import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultCallWidgetProviderTest {
|
||||
|
||||
@Test
|
||||
fun `getWidget - fails if the session does not exist`() = runTest {
|
||||
val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.failure(Exception("Session not found")) })
|
||||
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getWidget - fails if the room does not exist`() = runTest {
|
||||
val client = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, null)
|
||||
}
|
||||
val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
|
||||
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getWidget - fails if it can't generate the URL for the widget`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenGenerateWidgetWebViewUrlResult(Result.failure(Exception("Can't generate URL for widget")))
|
||||
}
|
||||
val client = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, room)
|
||||
}
|
||||
val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
|
||||
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getWidget - fails if it can't get the widget driver`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenGenerateWidgetWebViewUrlResult(Result.success("url"))
|
||||
givenGetWidgetDriverResult(Result.failure(Exception("Can't get a widget driver")))
|
||||
}
|
||||
val client = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, room)
|
||||
}
|
||||
val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
|
||||
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getWidget - returns a widget driver when all steps are successful`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenGenerateWidgetWebViewUrlResult(Result.success("url"))
|
||||
givenGetWidgetDriverResult(Result.success(FakeWidgetDriver()))
|
||||
}
|
||||
val client = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, room)
|
||||
}
|
||||
val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
|
||||
assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").getOrNull()).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getWidget - will use a custom base url if it exists`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenGenerateWidgetWebViewUrlResult(Result.success("url"))
|
||||
givenGetWidgetDriverResult(Result.success(FakeWidgetDriver()))
|
||||
}
|
||||
val client = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, room)
|
||||
}
|
||||
val preferencesStore = InMemoryPreferencesStore().apply {
|
||||
setCustomElementCallBaseUrl("https://custom.element.io")
|
||||
}
|
||||
val settingsProvider = FakeCallWidgetSettingsProvider()
|
||||
val provider = createProvider(
|
||||
matrixClientProvider = FakeMatrixClientProvider { Result.success(client) },
|
||||
callWidgetSettingsProvider = settingsProvider,
|
||||
preferencesStore = preferencesStore,
|
||||
)
|
||||
provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme")
|
||||
|
||||
assertThat(settingsProvider.providedBaseUrls).containsExactly("https://custom.element.io")
|
||||
}
|
||||
|
||||
private fun createProvider(
|
||||
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
|
||||
preferencesStore: PreferencesStore = InMemoryPreferencesStore(),
|
||||
callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider()
|
||||
) = DefaultCallWidgetProvider(
|
||||
matrixClientProvider,
|
||||
preferencesStore,
|
||||
callWidgetSettingsProvider,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.utils
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
|
||||
|
||||
class FakeCallWidgetProvider(
|
||||
private val widgetDriver: FakeWidgetDriver = FakeWidgetDriver(),
|
||||
private val url: String = "https://call.element.io",
|
||||
) : CallWidgetProvider {
|
||||
|
||||
var getWidgetCalled = false
|
||||
private set
|
||||
|
||||
override suspend fun getWidget(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId,
|
||||
clientId: String,
|
||||
languageTag: String?,
|
||||
theme: String?
|
||||
): Result<Pair<MatrixWidgetDriver, String>> {
|
||||
getWidgetCalled = true
|
||||
return Result.success(widgetDriver to url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call.utils
|
||||
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
||||
class FakeWidgetMessageInterceptor : WidgetMessageInterceptor {
|
||||
val sentMessages = mutableListOf<String>()
|
||||
|
||||
override val interceptedMessages = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
|
||||
override fun sendMessage(message: String) {
|
||||
sentMessages += message
|
||||
}
|
||||
|
||||
fun givenInterceptedMessage(message: String) {
|
||||
interceptedMessages.tryEmit(message)
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,11 @@ anvil {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
ksp(libs.showkase.processor)
|
||||
implementation(projects.anvilannotations)
|
||||
anvil(projects.anvilcodegen)
|
||||
api(projects.features.lockscreen.api)
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
@@ -40,6 +42,7 @@ dependencies {
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.cryptography.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
@@ -47,8 +50,8 @@ dependencies {
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(projects.libraries.cryptography.test)
|
||||
testImplementation(projects.libraries.cryptography.impl)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.lockscreen.impl.auth.PinAuthenticationNode
|
||||
import io.element.android.features.lockscreen.impl.create.CreatePinNode
|
||||
import io.element.android.features.lockscreen.impl.setup.SetupPinNode
|
||||
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
@@ -41,7 +41,7 @@ class LockScreenFlowNode @AssistedInject constructor(
|
||||
@Assisted plugins: List<Plugin>,
|
||||
) : BackstackNode<LockScreenFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Auth,
|
||||
initialElement = NavTarget.Unlock,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
@@ -50,19 +50,19 @@ class LockScreenFlowNode @AssistedInject constructor(
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Auth : NavTarget
|
||||
data object Unlock : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object Create : NavTarget
|
||||
data object Setup : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Auth -> {
|
||||
createNode<PinAuthenticationNode>(buildContext)
|
||||
NavTarget.Unlock -> {
|
||||
createNode<PinUnlockNode>(buildContext)
|
||||
}
|
||||
NavTarget.Create -> {
|
||||
createNode<CreatePinNode>(buildContext)
|
||||
NavTarget.Setup -> {
|
||||
createNode<SetupPinNode>(buildContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.auth
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.element.android.features.lockscreen.api.LockScreenStateService
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class PinAuthenticationPresenter @Inject constructor(
|
||||
private val pinStateService: LockScreenStateService,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) : Presenter<PinAuthenticationState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): PinAuthenticationState {
|
||||
|
||||
fun handleEvents(event: PinAuthenticationEvents) {
|
||||
when (event) {
|
||||
PinAuthenticationEvents.Unlock -> coroutineScope.launch { pinStateService.unlock() }
|
||||
}
|
||||
}
|
||||
return PinAuthenticationState(
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.auth
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
|
||||
@Composable
|
||||
fun PinAuthenticationView(
|
||||
state: PinAuthenticationState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(modifier) {
|
||||
HeaderFooterPage(
|
||||
modifier = Modifier
|
||||
.systemBarsPadding()
|
||||
.fillMaxSize(),
|
||||
header = { PinAuthenticationHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) },
|
||||
footer = { PinAuthenticationFooter(state) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinAuthenticationHeader(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
IconTitleSubtitleMolecule(
|
||||
modifier = modifier,
|
||||
title = "Element X is locked",
|
||||
subTitle = null,
|
||||
iconImageVector = Icons.Default.Lock,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinAuthenticationFooter(state: PinAuthenticationState) {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = "Unlock",
|
||||
onClick = {
|
||||
state.eventSink(PinAuthenticationEvents.Unlock)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@PreviewsDayNight
|
||||
internal fun PinAuthenticationViewPreview(@PreviewParameter(PinAuthenticationStateProvider::class) state: PinAuthenticationState) {
|
||||
ElementPreview {
|
||||
PinAuthenticationView(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinDigit
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.pinDigitBg
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
fun PinEntryTextField(
|
||||
pinEntry: PinEntry,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BasicTextField(
|
||||
modifier = modifier,
|
||||
value = TextFieldValue(pinEntry.toText()),
|
||||
onValueChange = {
|
||||
onValueChange(it.text)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
decorationBox = {
|
||||
PinEntryRow(pinEntry = pinEntry)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinEntryRow(
|
||||
pinEntry: PinEntry,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
for (digit in pinEntry.digits) {
|
||||
PinDigitView(digit = digit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinDigitView(
|
||||
digit: PinDigit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
val appearanceModifier = when (digit) {
|
||||
PinDigit.Empty -> {
|
||||
Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape)
|
||||
}
|
||||
is PinDigit.Filled -> {
|
||||
Modifier.background(ElementTheme.colors.pinDigitBg, shape)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(48.dp)
|
||||
.then(appearanceModifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
|
||||
) {
|
||||
if (digit is PinDigit.Filled) {
|
||||
Text(
|
||||
text = digit.toText(),
|
||||
style = ElementTheme.typography.fontHeadingMdBold
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PinEntryTextFieldPreview() {
|
||||
ElementPreview {
|
||||
PinEntryTextField(
|
||||
pinEntry = PinEntry.createEmpty(4).fillWith("12"),
|
||||
onValueChange = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.create
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import timber.log.Timber
|
||||
|
||||
@Composable
|
||||
fun CreatePinView(
|
||||
state: CreatePinState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Timber.d("CreatePinView: $state")
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"CreatePin feature view",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@PreviewsDayNight
|
||||
internal fun CreatePinViewPreview(@PreviewParameter(CreatePinStateProvider::class) state: CreatePinState) {
|
||||
ElementPreview {
|
||||
CreatePinView(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.pin.model
|
||||
|
||||
sealed interface PinDigit {
|
||||
data object Empty : PinDigit
|
||||
data class Filled(val value: Char) : PinDigit
|
||||
|
||||
fun toText(): String {
|
||||
return when (this) {
|
||||
is Empty -> ""
|
||||
is Filled -> value.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.pin.model
|
||||
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
data class PinEntry(
|
||||
val digits: ImmutableList<PinDigit>,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun createEmpty(size: Int): PinEntry {
|
||||
val digits = List(size) { PinDigit.Empty }
|
||||
return PinEntry(
|
||||
digits = digits.toPersistentList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val size = digits.size
|
||||
|
||||
/**
|
||||
* Fill the first digits with the given text.
|
||||
* Can't be more than the size of the PinEntry
|
||||
* Keep the Empty digits at the end
|
||||
* @return the new PinEntry
|
||||
*/
|
||||
fun fillWith(text: String): PinEntry {
|
||||
val newDigits = digits.toMutableList()
|
||||
text.forEachIndexed { index, char ->
|
||||
if (index < size) {
|
||||
newDigits[index] = PinDigit.Filled(char)
|
||||
}
|
||||
}
|
||||
return copy(digits = newDigits.toPersistentList())
|
||||
}
|
||||
|
||||
fun deleteLast(): PinEntry {
|
||||
if (isEmpty()) return this
|
||||
val newDigits = digits.toMutableList()
|
||||
newDigits.indexOfLast { it is PinDigit.Filled }.also { lastFilled ->
|
||||
newDigits[lastFilled] = PinDigit.Empty
|
||||
}
|
||||
return copy(digits = newDigits.toPersistentList())
|
||||
}
|
||||
|
||||
fun addDigit(digit: Char): PinEntry {
|
||||
if (isComplete()) return this
|
||||
val newDigits = digits.toMutableList()
|
||||
newDigits.indexOfFirst { it is PinDigit.Empty }.also { firstEmpty ->
|
||||
newDigits[firstEmpty] = PinDigit.Filled(digit)
|
||||
}
|
||||
return copy(digits = newDigits.toPersistentList())
|
||||
}
|
||||
|
||||
fun clear(): PinEntry {
|
||||
return createEmpty(size)
|
||||
}
|
||||
|
||||
fun isComplete(): Boolean {
|
||||
return digits.all { it is PinDigit.Filled }
|
||||
}
|
||||
|
||||
fun isEmpty(): Boolean {
|
||||
return digits.all { it is PinDigit.Empty }
|
||||
}
|
||||
|
||||
fun toText(): String {
|
||||
return digits.joinToString("") {
|
||||
it.toText()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
|
||||
sealed interface SetupPinEvents {
|
||||
data class OnPinEntryChanged(val entryAsText: String) : SetupPinEvents
|
||||
data object ClearFailure : SetupPinEvents
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.auth
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -27,17 +27,18 @@ import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class PinAuthenticationNode @AssistedInject constructor(
|
||||
class SetupPinNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: PinAuthenticationPresenter,
|
||||
private val presenter: SetupPinPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
PinAuthenticationView(
|
||||
SetupPinView(
|
||||
state = state,
|
||||
onBackClicked = { },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.appconfig.LockScreenConfig
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.PinValidator
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import javax.inject.Inject
|
||||
|
||||
class SetupPinPresenter @Inject constructor(
|
||||
private val pinValidator: PinValidator,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : Presenter<SetupPinState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): SetupPinState {
|
||||
var choosePinEntry by remember {
|
||||
mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE))
|
||||
}
|
||||
var confirmPinEntry by remember {
|
||||
mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE))
|
||||
}
|
||||
var isConfirmationStep by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var setupPinFailure by remember {
|
||||
mutableStateOf<SetupPinFailure?>(null)
|
||||
}
|
||||
|
||||
fun handleEvents(event: SetupPinEvents) {
|
||||
when (event) {
|
||||
is SetupPinEvents.OnPinEntryChanged -> {
|
||||
if (isConfirmationStep) {
|
||||
confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText)
|
||||
if (confirmPinEntry.isComplete()) {
|
||||
if (confirmPinEntry == choosePinEntry) {
|
||||
//TODO save in db and navigate to next screen
|
||||
} else {
|
||||
setupPinFailure = SetupPinFailure.PinsDontMatch
|
||||
}
|
||||
}
|
||||
} else {
|
||||
choosePinEntry = choosePinEntry.fillWith(event.entryAsText)
|
||||
if (choosePinEntry.isComplete()) {
|
||||
when (val pinValidationResult = pinValidator.isPinValid(choosePinEntry)) {
|
||||
is PinValidator.Result.Invalid -> {
|
||||
setupPinFailure = pinValidationResult.failure
|
||||
}
|
||||
PinValidator.Result.Valid -> isConfirmationStep = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SetupPinEvents.ClearFailure -> {
|
||||
when (setupPinFailure) {
|
||||
is SetupPinFailure.PinsDontMatch -> {
|
||||
choosePinEntry = choosePinEntry.clear()
|
||||
confirmPinEntry = confirmPinEntry.clear()
|
||||
}
|
||||
is SetupPinFailure.PinBlacklisted -> {
|
||||
choosePinEntry = choosePinEntry.clear()
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
isConfirmationStep = false
|
||||
setupPinFailure = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SetupPinState(
|
||||
choosePinEntry = choosePinEntry,
|
||||
confirmPinEntry = confirmPinEntry,
|
||||
isConfirmationStep = isConfirmationStep,
|
||||
setupPinFailure = setupPinFailure,
|
||||
appName = buildMeta.applicationName,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
|
||||
|
||||
data class SetupPinState(
|
||||
val choosePinEntry: PinEntry,
|
||||
val confirmPinEntry: PinEntry,
|
||||
val isConfirmationStep: Boolean,
|
||||
val setupPinFailure: SetupPinFailure?,
|
||||
val appName: String,
|
||||
val eventSink: (SetupPinEvents) -> Unit
|
||||
) {
|
||||
val activePinEntry = if (isConfirmationStep) {
|
||||
confirmPinEntry
|
||||
} else {
|
||||
choosePinEntry
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
|
||||
|
||||
open class SetupPinStateProvider : PreviewParameterProvider<SetupPinState> {
|
||||
override val values: Sequence<SetupPinState>
|
||||
get() = sequenceOf(
|
||||
aSetupPinState(),
|
||||
aSetupPinState(
|
||||
choosePinEntry = PinEntry.createEmpty(4).fillWith("12")
|
||||
),
|
||||
aSetupPinState(
|
||||
choosePinEntry = PinEntry.createEmpty(4).fillWith("1789"),
|
||||
isConfirmationStep = true,
|
||||
),
|
||||
aSetupPinState(
|
||||
choosePinEntry = PinEntry.createEmpty(4).fillWith("1789"),
|
||||
confirmPinEntry = PinEntry.createEmpty(4).fillWith("1788"),
|
||||
isConfirmationStep = true,
|
||||
creationFailure = SetupPinFailure.PinsDontMatch
|
||||
),
|
||||
aSetupPinState(
|
||||
choosePinEntry = PinEntry.createEmpty(4).fillWith("1111"),
|
||||
creationFailure = SetupPinFailure.PinBlacklisted
|
||||
),
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
fun aSetupPinState(
|
||||
choosePinEntry: PinEntry = PinEntry.createEmpty(4),
|
||||
confirmPinEntry: PinEntry = PinEntry.createEmpty(4),
|
||||
isConfirmationStep: Boolean = false,
|
||||
creationFailure: SetupPinFailure? = null,
|
||||
) = SetupPinState(
|
||||
choosePinEntry = choosePinEntry,
|
||||
confirmPinEntry = confirmPinEntry,
|
||||
isConfirmationStep = isConfirmationStep,
|
||||
setupPinFailure = creationFailure,
|
||||
appName = "Element",
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.lockscreen.impl.R
|
||||
import io.element.android.features.lockscreen.impl.components.PinEntryTextField
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
|
||||
@Composable
|
||||
fun SetupPinView(
|
||||
state: SetupPinState,
|
||||
onBackClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
BackButton(onClick = onBackClicked)
|
||||
},
|
||||
title = {}
|
||||
)
|
||||
},
|
||||
content = { padding ->
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.imePadding()
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.verticalScroll(state = scrollState)
|
||||
.padding(vertical = 16.dp, horizontal = 20.dp),
|
||||
) {
|
||||
SetupPinHeader(state.isConfirmationStep, state.appName)
|
||||
SetupPinContent(state)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupPinHeader(
|
||||
isValidationStep: Boolean,
|
||||
appName: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
IconTitleSubtitleMolecule(
|
||||
title = if (isValidationStep) {
|
||||
stringResource(id = R.string.screen_app_lock_setup_confirm_pin)
|
||||
} else {
|
||||
stringResource(id = R.string.screen_app_lock_setup_choose_pin)
|
||||
},
|
||||
subTitle = stringResource(id = R.string.screen_app_lock_setup_pin_context, appName),
|
||||
iconImageVector = Icons.Filled.Lock,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupPinContent(
|
||||
state: SetupPinState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
PinEntryTextField(
|
||||
state.activePinEntry,
|
||||
onValueChange = {
|
||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(it))
|
||||
},
|
||||
modifier = modifier
|
||||
.padding(top = 36.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
if (state.setupPinFailure != null) {
|
||||
ErrorDialog(
|
||||
modifier = modifier,
|
||||
title = state.setupPinFailure.title(),
|
||||
content = state.setupPinFailure.content(),
|
||||
onDismiss = {
|
||||
state.eventSink(SetupPinEvents.ClearFailure)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupPinFailure.content(): String {
|
||||
return when (this) {
|
||||
SetupPinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_content)
|
||||
SetupPinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupPinFailure.title(): String {
|
||||
return when (this) {
|
||||
SetupPinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_title)
|
||||
SetupPinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_title)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@PreviewsDayNight
|
||||
internal fun SetupPinViewPreview(@PreviewParameter(SetupPinStateProvider::class) state: SetupPinState) {
|
||||
ElementPreview {
|
||||
SetupPinView(
|
||||
state = state,
|
||||
onBackClicked = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup.validation
|
||||
|
||||
import io.element.android.appconfig.LockScreenConfig
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import javax.inject.Inject
|
||||
|
||||
class PinValidator internal constructor(private val pinBlacklist: Set<String>) {
|
||||
|
||||
@Inject
|
||||
constructor() : this(LockScreenConfig.PIN_BLACKLIST)
|
||||
|
||||
sealed interface Result {
|
||||
data object Valid : Result
|
||||
data class Invalid(val failure: SetupPinFailure) : Result
|
||||
}
|
||||
|
||||
fun isPinValid(pinEntry: PinEntry): Result {
|
||||
val pinAsText = pinEntry.toText()
|
||||
val isBlacklisted = pinBlacklist.any { it == pinAsText }
|
||||
return if (isBlacklisted) {
|
||||
Result.Invalid(SetupPinFailure.PinBlacklisted)
|
||||
} else {
|
||||
Result.Valid
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup.validation
|
||||
|
||||
sealed interface SetupPinFailure {
|
||||
data object PinBlacklisted : SetupPinFailure
|
||||
data object PinsDontMatch : SetupPinFailure
|
||||
}
|
||||
@@ -25,13 +25,12 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val GRACE_PERIOD_IN_MILLIS = 90 * 1000L
|
||||
//private const val GRACE_PERIOD_IN_MILLIS = 90 * 1000L
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
@@ -57,7 +56,7 @@ class DefaultLockScreenStateService @Inject constructor(
|
||||
override suspend fun entersBackground() = coroutineScope {
|
||||
lockJob = launch {
|
||||
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)) {
|
||||
delay(GRACE_PERIOD_IN_MILLIS)
|
||||
//delay(GRACE_PERIOD_IN_MILLIS)
|
||||
_lockScreenState.value = LockScreenState.Locked
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.unlock
|
||||
|
||||
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
|
||||
|
||||
sealed interface PinUnlockEvents {
|
||||
data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvents
|
||||
data object OnForgetPin : PinUnlockEvents
|
||||
data object ClearSignOutPrompt : PinUnlockEvents
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.create
|
||||
package io.element.android.features.lockscreen.impl.unlock
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -27,16 +27,16 @@ import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class CreatePinNode @AssistedInject constructor(
|
||||
class PinUnlockNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: CreatePinPresenter,
|
||||
private val presenter: PinUnlockPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
CreatePinView(
|
||||
PinUnlockView(
|
||||
state = state,
|
||||
modifier = modifier
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.unlock
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.appconfig.LockScreenConfig
|
||||
import io.element.android.features.lockscreen.api.LockScreenStateService
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class PinUnlockPresenter @Inject constructor(
|
||||
private val pinStateService: LockScreenStateService,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) : Presenter<PinUnlockState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): PinUnlockState {
|
||||
var pinEntry by remember {
|
||||
//TODO fetch size from db
|
||||
mutableStateOf(PinEntry.createEmpty(LockScreenConfig.PIN_SIZE))
|
||||
}
|
||||
var remainingAttempts by rememberSaveable {
|
||||
//TODO fetch from db
|
||||
mutableIntStateOf(3)
|
||||
}
|
||||
var showWrongPinTitle by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var showSignOutPrompt by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
fun handleEvents(event: PinUnlockEvents) {
|
||||
when (event) {
|
||||
is PinUnlockEvents.OnPinKeypadPressed -> {
|
||||
pinEntry = pinEntry.process(event.pinKeypadModel)
|
||||
if (pinEntry.isComplete()) {
|
||||
//TODO check pin with PinCodeManager
|
||||
coroutineScope.launch { pinStateService.unlock() }
|
||||
}
|
||||
}
|
||||
PinUnlockEvents.OnForgetPin -> showSignOutPrompt = true
|
||||
PinUnlockEvents.ClearSignOutPrompt -> showSignOutPrompt = false
|
||||
}
|
||||
}
|
||||
return PinUnlockState(
|
||||
pinEntry = pinEntry,
|
||||
showWrongPinTitle = showWrongPinTitle,
|
||||
remainingAttempts = remainingAttempts,
|
||||
showSignOutPrompt = showSignOutPrompt,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun PinEntry.process(pinKeypadModel: PinKeypadModel): PinEntry {
|
||||
return when (pinKeypadModel) {
|
||||
PinKeypadModel.Back -> deleteLast()
|
||||
is PinKeypadModel.Number -> addDigit(pinKeypadModel.number)
|
||||
PinKeypadModel.Empty -> this
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.unlock
|
||||
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
|
||||
data class PinUnlockState(
|
||||
val pinEntry: PinEntry,
|
||||
val showWrongPinTitle: Boolean,
|
||||
val remainingAttempts: Int,
|
||||
val showSignOutPrompt: Boolean,
|
||||
val eventSink: (PinUnlockEvents) -> Unit
|
||||
) {
|
||||
val isSignOutPromptCancellable = remainingAttempts > 0
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.unlock
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
|
||||
open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
|
||||
override val values: Sequence<PinUnlockState>
|
||||
get() = sequenceOf(
|
||||
aPinUnlockState(),
|
||||
aPinUnlockState(pinEntry = PinEntry.createEmpty(4).fillWith("12")),
|
||||
aPinUnlockState(showWrongPinTitle = true),
|
||||
aPinUnlockState(showSignOutPrompt = true),
|
||||
aPinUnlockState(showSignOutPrompt = true, remainingAttempts = 0),
|
||||
)
|
||||
}
|
||||
|
||||
fun aPinUnlockState(
|
||||
pinEntry: PinEntry = PinEntry.createEmpty(4),
|
||||
remainingAttempts: Int = 3,
|
||||
showWrongPinTitle: Boolean = false,
|
||||
showSignOutPrompt: Boolean = false,
|
||||
) = PinUnlockState(
|
||||
pinEntry = pinEntry,
|
||||
showWrongPinTitle = showWrongPinTitle,
|
||||
remainingAttempts = remainingAttempts,
|
||||
showSignOutPrompt = showSignOutPrompt,
|
||||
eventSink = {}
|
||||
)
|
||||
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.unlock
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxWithConstraintsScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.lockscreen.impl.R
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinDigit
|
||||
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypad
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun PinUnlockView(
|
||||
state: PinUnlockState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(modifier) {
|
||||
BoxWithConstraints {
|
||||
val commonModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.padding(all = 20.dp)
|
||||
|
||||
val header = @Composable {
|
||||
PinUnlockHeader(
|
||||
state = state,
|
||||
modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)
|
||||
)
|
||||
}
|
||||
val footer = @Composable {
|
||||
PinUnlockFooter(
|
||||
modifier = Modifier.padding(top = 24.dp)
|
||||
)
|
||||
}
|
||||
val content = @Composable { constraints: BoxWithConstraintsScope ->
|
||||
PinKeypad(
|
||||
onClick = {
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(it))
|
||||
},
|
||||
maxWidth = constraints.maxWidth,
|
||||
maxHeight = constraints.maxHeight,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
)
|
||||
}
|
||||
if (maxHeight < 600.dp) {
|
||||
PinUnlockCompactView(
|
||||
header = header,
|
||||
footer = footer,
|
||||
content = content,
|
||||
modifier = commonModifier,
|
||||
)
|
||||
} else {
|
||||
PinUnlockExpandedView(
|
||||
header = header,
|
||||
footer = footer,
|
||||
content = content,
|
||||
modifier = commonModifier,
|
||||
)
|
||||
}
|
||||
if (state.showSignOutPrompt) {
|
||||
if (state.isSignOutPromptCancellable) {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(id = R.string.screen_app_lock_signout_alert_title),
|
||||
content = stringResource(id = R.string.screen_app_lock_signout_alert_message),
|
||||
onSubmitClicked = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
} else {
|
||||
ErrorDialog(
|
||||
title = stringResource(id = R.string.screen_app_lock_signout_alert_title),
|
||||
content = stringResource(id = R.string.screen_app_lock_signout_alert_message),
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinUnlockCompactView(
|
||||
header: @Composable () -> Unit,
|
||||
footer: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable BoxWithConstraintsScope.() -> Unit,
|
||||
) {
|
||||
Row(modifier = modifier) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
header()
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
footer()
|
||||
}
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinUnlockExpandedView(
|
||||
header: @Composable () -> Unit,
|
||||
footer: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable BoxWithConstraintsScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
header()
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 40.dp),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
footer()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinDotsRow(
|
||||
pinEntry: PinEntry,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(modifier, horizontalArrangement = spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
for (digit in pinEntry.digits) {
|
||||
PinDot(isFilled = digit is PinDigit.Filled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinDot(
|
||||
isFilled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val backgroundColor = if (isFilled) {
|
||||
ElementTheme.colors.iconPrimary
|
||||
} else {
|
||||
ElementTheme.colors.bgSubtlePrimary
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(14.dp)
|
||||
.background(backgroundColor, CircleShape)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinUnlockHeader(
|
||||
state: PinUnlockState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(32.dp),
|
||||
tint = ElementTheme.colors.iconPrimary,
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = "",
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(id = CommonStrings.common_enter_your_pin),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = ElementTheme.typography.fontHeadingMdBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val subtitle = if (state.showWrongPinTitle) {
|
||||
pluralStringResource(id = R.plurals.screen_app_lock_subtitle_wrong_pin, count = state.remainingAttempts, state.remainingAttempts)
|
||||
} else {
|
||||
stringResource(id = R.string.screen_app_lock_subtitle)
|
||||
}
|
||||
val subtitleColor = if (state.showWrongPinTitle) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.secondary
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = subtitleColor,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
PinDotsRow(state.pinEntry)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinUnlockFooter(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) {
|
||||
TextButton(text = "Use biometric", onClick = { })
|
||||
TextButton(text = stringResource(id = R.string.screen_app_lock_forgot_pin), onClick = { })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@PreviewsDayNight
|
||||
internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
|
||||
ElementPreview {
|
||||
PinUnlockView(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.unlock.keypad
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Backspace
|
||||
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.clip
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.coerceAtMost
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.times
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toSp
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
private val spaceBetweenPinKey = 16.dp
|
||||
private val maxSizePinKey = 80.dp
|
||||
|
||||
@Composable
|
||||
fun PinKeypad(
|
||||
onClick: (PinKeypadModel) -> Unit,
|
||||
maxWidth: Dp,
|
||||
maxHeight: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
verticalAlignment: Alignment.Vertical = Alignment.Top,
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
) {
|
||||
val pinKeyMaxWidth = ((maxWidth - 2 * spaceBetweenPinKey) / 3).coerceAtMost(maxSizePinKey)
|
||||
val pinKeyMaxHeight = ((maxHeight - 3 * spaceBetweenPinKey) / 4).coerceAtMost(maxSizePinKey)
|
||||
val pinKeySize = if (pinKeyMaxWidth < pinKeyMaxHeight) pinKeyMaxWidth else pinKeyMaxHeight
|
||||
|
||||
val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally)
|
||||
val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically)
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = verticalArrangement,
|
||||
horizontalAlignment = horizontalAlignment,
|
||||
) {
|
||||
PinKeypadRow(
|
||||
pinKeySize = pinKeySize,
|
||||
verticalAlignment = verticalAlignment,
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
models = persistentListOf(PinKeypadModel.Number('1'), PinKeypadModel.Number('2'), PinKeypadModel.Number('3')),
|
||||
onClick = onClick,
|
||||
)
|
||||
PinKeypadRow(
|
||||
pinKeySize = pinKeySize,
|
||||
verticalAlignment = verticalAlignment,
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
models = persistentListOf(PinKeypadModel.Number('4'), PinKeypadModel.Number('5'), PinKeypadModel.Number('6')),
|
||||
onClick = onClick,
|
||||
)
|
||||
PinKeypadRow(
|
||||
pinKeySize = pinKeySize,
|
||||
verticalAlignment = verticalAlignment,
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
models = persistentListOf(PinKeypadModel.Number('7'), PinKeypadModel.Number('8'), PinKeypadModel.Number('9')),
|
||||
onClick = onClick,
|
||||
)
|
||||
PinKeypadRow(
|
||||
pinKeySize = pinKeySize,
|
||||
verticalAlignment = verticalAlignment,
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
models = persistentListOf(PinKeypadModel.Empty, PinKeypadModel.Number('0'), PinKeypadModel.Back),
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinKeypadRow(
|
||||
models: ImmutableList<PinKeypadModel>,
|
||||
onClick: (PinKeypadModel) -> Unit,
|
||||
pinKeySize: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
|
||||
verticalAlignment: Alignment.Vertical = Alignment.Top,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
verticalAlignment = verticalAlignment,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
val commonModifier = Modifier.size(pinKeySize)
|
||||
for (model in models) {
|
||||
when (model) {
|
||||
is PinKeypadModel.Empty -> {
|
||||
Spacer(modifier = commonModifier)
|
||||
}
|
||||
is PinKeypadModel.Back -> {
|
||||
PinKeypadBackButton(
|
||||
modifier = commonModifier,
|
||||
onClick = { onClick(model) },
|
||||
)
|
||||
}
|
||||
is PinKeypadModel.Number -> {
|
||||
PinKeyBadDigitButton(
|
||||
size = pinKeySize,
|
||||
modifier = commonModifier,
|
||||
digit = model.number.toString(),
|
||||
onClick = { onClick(model) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinKeypadButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.clip(CircleShape)
|
||||
.background(color = ElementTheme.colors.bgSubtlePrimary)
|
||||
.clickable(onClick = onClick),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinKeyBadDigitButton(
|
||||
digit: String,
|
||||
size: Dp,
|
||||
onClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
PinKeypadButton(
|
||||
modifier = modifier,
|
||||
onClick = { onClick(digit) }
|
||||
) {
|
||||
val fontSize = size.toSp() / 2
|
||||
val originalFont = ElementTheme.typography.fontHeadingXlBold
|
||||
val ratio = fontSize.value / originalFont.fontSize.value
|
||||
val lineHeight = originalFont.lineHeight * ratio
|
||||
Text(
|
||||
text = digit,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinKeypadBackButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
PinKeypadButton(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Backspace,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@PreviewsDayNight
|
||||
internal fun PinKeypadPreview() {
|
||||
ElementPreview {
|
||||
BoxWithConstraints {
|
||||
PinKeypad(
|
||||
maxWidth = maxWidth,
|
||||
maxHeight = maxHeight,
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.unlock.keypad
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed interface PinKeypadModel {
|
||||
data object Empty : PinKeypadModel
|
||||
data object Back : PinKeypadModel
|
||||
data class Number(val number: Char) : PinKeypadModel
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<plurals name="screen_app_lock_subtitle_wrong_pin">
|
||||
<item quantity="one">"Nesprávny PIN kód. Máte ešte %1$d pokus"</item>
|
||||
<item quantity="few">"Nesprávny PIN kód. Máte ešte %1$d pokusy"</item>
|
||||
<item quantity="other">"Nesprávny PIN kód. Máte ešte %1$d pokusov"</item>
|
||||
</plurals>
|
||||
<string name="screen_app_lock_forgot_pin">"Zabudli ste PIN?"</string>
|
||||
<string name="screen_app_lock_settings_change_pin">"Zmeniť PIN kód"</string>
|
||||
<string name="screen_app_lock_settings_enable_biometric_unlock">"Povoliť biometrické odomknutie"</string>
|
||||
<string name="screen_app_lock_settings_remove_pin">"Odstrániť PIN"</string>
|
||||
<string name="screen_app_lock_settings_remove_pin_alert_message">"Ste si istí, že chcete odstrániť PIN?"</string>
|
||||
<string name="screen_app_lock_settings_remove_pin_alert_title">"Odstrániť PIN?"</string>
|
||||
<string name="screen_app_lock_setup_choose_pin">"Vyberte PIN"</string>
|
||||
<string name="screen_app_lock_setup_confirm_pin">"Potvrdiť PIN"</string>
|
||||
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"Z bezpečnostných dôvodov si nemôžete toto zvoliť ako svoj PIN kód."</string>
|
||||
<string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"Vyberte iný PIN"</string>
|
||||
<string name="screen_app_lock_setup_pin_context">"Uzamknite %1$s, aby ste zvýšili bezpečnosť svojich konverzácií.
|
||||
|
||||
Vyberte si niečo zapamätateľné. Ak tento kód PIN zabudnete, budete z aplikácie odhlásení."</string>
|
||||
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Zadajte prosím ten istý PIN dvakrát"</string>
|
||||
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN kódy sa nezhodujú"</string>
|
||||
<string name="screen_app_lock_signout_alert_message">"Ak chcete pokračovať, musíte sa znovu prihlásiť a vytvoriť nový PIN kód."</string>
|
||||
<string name="screen_app_lock_signout_alert_title">"Prebieha odhlasovanie"</string>
|
||||
<string name="screen_app_lock_subtitle">"Máte 3 pokusy na odomknutie"</string>
|
||||
</resources>
|
||||
25
features/lockscreen/impl/src/main/res/values/localazy.xml
Normal file
25
features/lockscreen/impl/src/main/res/values/localazy.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<plurals name="screen_app_lock_subtitle_wrong_pin">
|
||||
<item quantity="one">"Wrong PIN. You have %1$d more chance"</item>
|
||||
<item quantity="other">"Wrong PIN. You have %1$d more chances"</item>
|
||||
</plurals>
|
||||
<string name="screen_app_lock_forgot_pin">"Forgot PIN?"</string>
|
||||
<string name="screen_app_lock_settings_change_pin">"Change PIN code"</string>
|
||||
<string name="screen_app_lock_settings_enable_biometric_unlock">"Allow biometric unlock"</string>
|
||||
<string name="screen_app_lock_settings_remove_pin">"Remove PIN"</string>
|
||||
<string name="screen_app_lock_settings_remove_pin_alert_message">"Are you sure you want to remove PIN?"</string>
|
||||
<string name="screen_app_lock_settings_remove_pin_alert_title">"Remove PIN?"</string>
|
||||
<string name="screen_app_lock_setup_choose_pin">"Choose PIN"</string>
|
||||
<string name="screen_app_lock_setup_confirm_pin">"Confirm PIN"</string>
|
||||
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"You cannot choose this as your PIN code for security reasons"</string>
|
||||
<string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"Choose a different PIN"</string>
|
||||
<string name="screen_app_lock_setup_pin_context">"Lock %1$s to add extra security to your chats.
|
||||
|
||||
Choose something memorable. If you forget this PIN, you will be logged out of the app."</string>
|
||||
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Please enter the same PIN twice"</string>
|
||||
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PINs don\'t match"</string>
|
||||
<string name="screen_app_lock_signout_alert_message">"You’ll need to re-login and create a new PIN to proceed"</string>
|
||||
<string name="screen_app_lock_signout_alert_title">"You are being signed out"</string>
|
||||
<string name="screen_app_lock_subtitle">"You have 3 attempts to unlock"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.pin.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
|
||||
fun PinEntry.assertText(text: String) {
|
||||
assertThat(toText()).isEqualTo(text)
|
||||
}
|
||||
|
||||
fun PinEntry.assertEmpty() {
|
||||
val isEmpty = digits.all { it is PinDigit.Empty }
|
||||
assertThat(isEmpty).isTrue()
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.setup
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.lockscreen.impl.pin.model.assertEmpty
|
||||
import io.element.android.features.lockscreen.impl.pin.model.assertText
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.PinValidator
|
||||
import io.element.android.features.lockscreen.impl.setup.validation.SetupPinFailure
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class SetupPinPresenterTest {
|
||||
|
||||
private val blacklistedPin = "1234"
|
||||
private val halfCompletePin = "12"
|
||||
private val completePin = "1235"
|
||||
private val mismatchedPin = "1236"
|
||||
|
||||
@Test
|
||||
fun `present - complete flow`() = runTest {
|
||||
|
||||
val presenter = createSetupPinPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().also { state ->
|
||||
state.choosePinEntry.assertEmpty()
|
||||
state.confirmPinEntry.assertEmpty()
|
||||
assertThat(state.setupPinFailure).isNull()
|
||||
assertThat(state.isConfirmationStep).isFalse()
|
||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(halfCompletePin))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
state.choosePinEntry.assertText(halfCompletePin)
|
||||
state.confirmPinEntry.assertEmpty()
|
||||
assertThat(state.setupPinFailure).isNull()
|
||||
assertThat(state.isConfirmationStep).isFalse()
|
||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(blacklistedPin))
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.choosePinEntry.assertText(blacklistedPin)
|
||||
assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinBlacklisted)
|
||||
state.eventSink(SetupPinEvents.ClearFailure)
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.choosePinEntry.assertEmpty()
|
||||
assertThat(state.setupPinFailure).isNull()
|
||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.choosePinEntry.assertText(completePin)
|
||||
state.confirmPinEntry.assertEmpty()
|
||||
assertThat(state.isConfirmationStep).isTrue()
|
||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(mismatchedPin))
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.choosePinEntry.assertText(completePin)
|
||||
state.confirmPinEntry.assertText(mismatchedPin)
|
||||
assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinsDontMatch)
|
||||
state.eventSink(SetupPinEvents.ClearFailure)
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.choosePinEntry.assertEmpty()
|
||||
state.confirmPinEntry.assertEmpty()
|
||||
assertThat(state.isConfirmationStep).isFalse()
|
||||
assertThat(state.setupPinFailure).isNull()
|
||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.choosePinEntry.assertText(completePin)
|
||||
state.confirmPinEntry.assertEmpty()
|
||||
assertThat(state.isConfirmationStep).isTrue()
|
||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
state.choosePinEntry.assertText(completePin)
|
||||
state.confirmPinEntry.assertText(completePin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSetupPinPresenter(): SetupPinPresenter {
|
||||
return SetupPinPresenter(PinValidator(setOf(blacklistedPin)), aBuildMeta())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.lockscreen.impl.unlock
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.lockscreen.impl.pin.model.assertEmpty
|
||||
import io.element.android.features.lockscreen.impl.pin.model.assertText
|
||||
import io.element.android.features.lockscreen.impl.state.DefaultLockScreenStateService
|
||||
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class PinUnlockPresenterTest {
|
||||
|
||||
private val halfCompletePin = "12"
|
||||
private val completePin = "1235"
|
||||
|
||||
@Test
|
||||
fun `present - complete flow`() = runTest {
|
||||
val presenter = createPinUnlockPresenter(this)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().also { state ->
|
||||
state.pinEntry.assertEmpty()
|
||||
assertThat(state.showWrongPinTitle).isFalse()
|
||||
assertThat(state.showSignOutPrompt).isFalse()
|
||||
assertThat(state.remainingAttempts).isEqualTo(3)
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1')))
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2')))
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.pinEntry.assertText(halfCompletePin)
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Back))
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Empty))
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.pinEntry.assertText(halfCompletePin)
|
||||
state.eventSink(PinUnlockEvents.OnForgetPin)
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
assertThat(state.showSignOutPrompt).isEqualTo(true)
|
||||
assertThat(state.isSignOutPromptCancellable).isEqualTo(true)
|
||||
state.eventSink(PinUnlockEvents.ClearSignOutPrompt)
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
assertThat(state.showSignOutPrompt).isEqualTo(false)
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
|
||||
state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('5')))
|
||||
}
|
||||
awaitLastSequentialItem().also { state ->
|
||||
state.pinEntry.assertText(completePin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createPinUnlockPresenter(scope: CoroutineScope): PinUnlockPresenter {
|
||||
val featureFlagService = FakeFeatureFlagService().apply {
|
||||
setFeatureEnabled(FeatureFlags.PinUnlock, true)
|
||||
}
|
||||
val lockScreenStateService = DefaultLockScreenStateService(featureFlagService)
|
||||
return PinUnlockPresenter(
|
||||
lockScreenStateService,
|
||||
scope,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ anvil {
|
||||
|
||||
dependencies {
|
||||
implementation(projects.anvilannotations)
|
||||
implementation(projects.appconfig)
|
||||
anvil(projects.anvilcodegen)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.androidutils)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
package io.element.android.features.login.impl.accountprovider
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.login.impl.util.LoginConstants
|
||||
import io.element.android.appconfig.AuthenticationConfig
|
||||
|
||||
open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> {
|
||||
override val values: Sequence<AccountProvider>
|
||||
@@ -32,7 +32,7 @@ open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> {
|
||||
}
|
||||
|
||||
fun anAccountProvider() = AccountProvider(
|
||||
url = LoginConstants.MATRIX_ORG_URL,
|
||||
url = AuthenticationConfig.MATRIX_ORG_URL,
|
||||
subtitle = "Matrix.org is an open network for secure, decentralized communication.",
|
||||
isPublic = true,
|
||||
isMatrixOrg = true,
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
package io.element.android.features.login.impl.screens.changeaccountprovider
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.element.android.appconfig.AuthenticationConfig
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
|
||||
import io.element.android.features.login.impl.util.LoginConstants
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -34,7 +34,7 @@ class ChangeAccountProviderPresenter @Inject constructor(
|
||||
// Just matrix.org by default for now
|
||||
accountProviders = listOf(
|
||||
AccountProvider(
|
||||
url = LoginConstants.MATRIX_ORG_URL,
|
||||
url = AuthenticationConfig.MATRIX_ORG_URL,
|
||||
subtitle = null,
|
||||
isPublic = true,
|
||||
isMatrixOrg = true,
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
package io.element.android.features.login.impl.screens.searchaccountprovider
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.appconfig.AuthenticationConfig
|
||||
import io.element.android.features.login.impl.changeserver.aChangeServerState
|
||||
import io.element.android.features.login.impl.resolver.HomeserverData
|
||||
import io.element.android.features.login.impl.util.LoginConstants
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
||||
open class SearchAccountProviderStateProvider : PreviewParameterProvider<SearchAccountProviderState> {
|
||||
@@ -50,7 +50,7 @@ fun aHomeserverDataList(): List<HomeserverData> {
|
||||
}
|
||||
|
||||
fun aHomeserverData(
|
||||
homeserverUrl: String = LoginConstants.MATRIX_ORG_URL,
|
||||
homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL,
|
||||
isWellknownValid: Boolean = true,
|
||||
supportSlidingSync: Boolean = true,
|
||||
): HomeserverData {
|
||||
|
||||
@@ -14,12 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package io.element.android.features.login.impl.screens.searchaccountprovider
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -48,13 +47,13 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.appconfig.AuthenticationConfig
|
||||
import io.element.android.features.login.impl.R
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProviderView
|
||||
import io.element.android.features.login.impl.changeserver.ChangeServerEvents
|
||||
import io.element.android.features.login.impl.changeserver.ChangeServerView
|
||||
import io.element.android.features.login.impl.resolver.HomeserverData
|
||||
import io.element.android.features.login.impl.util.LoginConstants
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
@@ -196,7 +195,7 @@ fun SearchAccountProviderView(
|
||||
|
||||
@Composable
|
||||
private fun HomeserverData.toAccountProvider(): AccountProvider {
|
||||
val isMatrixOrg = homeserverUrl == LoginConstants.MATRIX_ORG_URL
|
||||
val isMatrixOrg = homeserverUrl == AuthenticationConfig.MATRIX_ORG_URL
|
||||
return AccountProvider(
|
||||
url = homeserverUrl,
|
||||
subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null,
|
||||
|
||||
@@ -16,18 +16,12 @@
|
||||
|
||||
package io.element.android.features.login.impl.util
|
||||
|
||||
import io.element.android.appconfig.AuthenticationConfig
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||
|
||||
object LoginConstants {
|
||||
const val MATRIX_ORG_URL = "https://matrix.org"
|
||||
|
||||
const val DEFAULT_HOMESERVER_URL = "https://matrix.org"
|
||||
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
|
||||
}
|
||||
|
||||
val defaultAccountProvider = AccountProvider(
|
||||
url = LoginConstants.DEFAULT_HOMESERVER_URL,
|
||||
url = AuthenticationConfig.DEFAULT_HOMESERVER_URL,
|
||||
subtitle = null,
|
||||
isPublic = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL,
|
||||
isMatrixOrg = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL,
|
||||
isPublic = AuthenticationConfig.DEFAULT_HOMESERVER_URL == AuthenticationConfig.MATRIX_ORG_URL,
|
||||
isMatrixOrg = AuthenticationConfig.DEFAULT_HOMESERVER_URL == AuthenticationConfig.MATRIX_ORG_URL,
|
||||
)
|
||||
|
||||
@@ -19,9 +19,10 @@ package io.element.android.features.login.impl.util
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import io.element.android.appconfig.AuthenticationConfig
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
|
||||
fun openLearnMorePage(context: Context) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LoginConstants.SLIDING_SYNC_READ_MORE_URL))
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(AuthenticationConfig.SLIDING_SYNC_READ_MORE_URL))
|
||||
tryOrNull { context.startActivity(intent) }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_signout_backing_up_subtitle">"Prosím, počkajte na dokončenie tohto kroku a až potom sa odhláste."</string>
|
||||
<string name="screen_signout_backing_up_title">"Vaše kľúče sa ešte stále zálohujú"</string>
|
||||
<string name="screen_signout_confirmation_dialog_content">"Ste si istí, že sa chcete odhlásiť?"</string>
|
||||
<string name="screen_signout_confirmation_dialog_title">"Odhlásiť sa"</string>
|
||||
<string name="screen_signout_in_progress_dialog_content">"Prebieha odhlasovanie…"</string>
|
||||
<string name="screen_signout_last_session_subtitle">"Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, môžete stratiť prístup k svojim šifrovaným správam."</string>
|
||||
<string name="screen_signout_last_session_title">"Uložili ste kľúč na obnovenie?"</string>
|
||||
<string name="screen_signout_confirmation_dialog_submit">"Odhlásiť sa"</string>
|
||||
<string name="screen_signout_preference_item">"Odhlásiť sa"</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_signout_backing_up_subtitle">"Please wait for this to complete before signing out."</string>
|
||||
<string name="screen_signout_backing_up_title">"Your keys are still being backed up"</string>
|
||||
<string name="screen_signout_confirmation_dialog_content">"Are you sure you want to sign out?"</string>
|
||||
<string name="screen_signout_confirmation_dialog_title">"Sign out"</string>
|
||||
<string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>
|
||||
<string name="screen_signout_last_session_subtitle">"You are about to sign out of your last session. If you sign out now, you might lose access to your encrypted messages."</string>
|
||||
<string name="screen_signout_last_session_title">"Recovery not set up"</string>
|
||||
<string name="screen_signout_confirmation_dialog_submit">"Sign out"</string>
|
||||
<string name="screen_signout_preference_item">"Sign out"</string>
|
||||
</resources>
|
||||
|
||||
@@ -33,6 +33,7 @@ dependencies {
|
||||
implementation(projects.anvilannotations)
|
||||
anvil(projects.anvilcodegen)
|
||||
api(projects.features.messages.api)
|
||||
implementation(projects.features.call)
|
||||
implementation(projects.features.location.api)
|
||||
implementation(projects.features.poll.api)
|
||||
implementation(projects.libraries.androidutils)
|
||||
@@ -50,6 +51,7 @@ dependencies {
|
||||
implementation(projects.libraries.mediaupload.api)
|
||||
implementation(projects.libraries.permissions.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.voicerecorder.api)
|
||||
implementation(projects.features.networkmonitor.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(libs.coil.compose)
|
||||
@@ -79,6 +81,7 @@ dependencies {
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.libraries.textcomposer.test)
|
||||
testImplementation(projects.libraries.voicerecorder.test)
|
||||
testImplementation(libs.test.mockk)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
20
features/messages/impl/src/main/AndroidManifest.xml
Normal file
20
features/messages/impl/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2023 New Vector Ltd
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
</manifest>
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package io.element.android.features.messages.impl
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -29,6 +30,8 @@ import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.call.CallType
|
||||
import io.element.android.features.call.ui.ElementCallActivity
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.location.api.SendLocationEntryPoint
|
||||
import io.element.android.features.location.api.ShowLocationEntryPoint
|
||||
@@ -50,7 +53,9 @@ import io.element.android.features.poll.api.create.CreatePollEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
@@ -63,6 +68,8 @@ import kotlinx.parcelize.Parcelize
|
||||
class MessagesFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val sendLocationEntryPoint: SendLocationEntryPoint,
|
||||
private val showLocationEntryPoint: ShowLocationEntryPoint,
|
||||
private val createPollEntryPoint: CreatePollEntryPoint,
|
||||
@@ -149,6 +156,14 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
override fun onCreatePollClicked() {
|
||||
backstack.push(NavTarget.CreatePoll)
|
||||
}
|
||||
|
||||
override fun onJoinCallClicked(roomId: RoomId) {
|
||||
val inputs = CallType.RoomCall(
|
||||
sessionId = matrixClient.sessionId,
|
||||
roomId = roomId,
|
||||
)
|
||||
ElementCallActivity.start(context, inputs)
|
||||
}
|
||||
}
|
||||
createNode<MessagesNode>(buildContext, listOf(callback))
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPr
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
@@ -63,6 +64,7 @@ class MessagesNode @AssistedInject constructor(
|
||||
fun onReportMessage(eventId: EventId, senderId: UserId)
|
||||
fun onSendLocationClicked()
|
||||
fun onCreatePollClicked()
|
||||
fun onJoinCallClicked(roomId: RoomId)
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -108,6 +110,10 @@ class MessagesNode @AssistedInject constructor(
|
||||
callback?.onCreatePollClicked()
|
||||
}
|
||||
|
||||
private fun onJoinCallClicked() {
|
||||
callback?.onJoinCallClicked(room.roomId)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
CompositionLocalProvider(
|
||||
@@ -123,6 +129,7 @@ class MessagesNode @AssistedInject constructor(
|
||||
onUserDataClicked = this::onUserDataClicked,
|
||||
onSendLocationClicked = this::onSendLocationClicked,
|
||||
onCreatePollClicked = this::onCreatePollClicked,
|
||||
onJoinCallClicked = this::onJoinCallClicked,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.preferences.api.store.PreferencesStore
|
||||
@@ -63,6 +63,7 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
@@ -101,6 +102,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
private val preferencesStore: PreferencesStore,
|
||||
private val featureFlagsService: FeatureFlagService,
|
||||
@Assisted private val navigator: MessagesNavigator,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : Presenter<MessagesState> {
|
||||
|
||||
@AssistedFactory
|
||||
@@ -152,8 +154,10 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
|
||||
|
||||
var enableVoiceMessages by remember { mutableStateOf(false) }
|
||||
var enableInRoomCalls by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(featureFlagsService) {
|
||||
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
|
||||
enableInRoomCalls = featureFlagsService.isFeatureEnabled(FeatureFlags.InRoomCalls)
|
||||
}
|
||||
|
||||
fun handleEvents(event: MessagesEvents) {
|
||||
@@ -200,6 +204,8 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
inviteProgress = inviteProgress.value,
|
||||
enableTextFormatting = enableTextFormatting,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
enableInRoomCalls = enableInRoomCalls,
|
||||
appName = buildMeta.applicationName,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import io.element.android.features.messages.impl.timeline.TimelineState
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
@@ -49,5 +49,7 @@ data class MessagesState(
|
||||
val showReinvitePrompt: Boolean,
|
||||
val enableTextFormatting: Boolean,
|
||||
val enableVoiceMessages: Boolean,
|
||||
val enableInRoomCalls: Boolean,
|
||||
val appName: String,
|
||||
val eventSink: (MessagesEvents) -> Unit
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.messages.impl.actionlist.anActionListState
|
||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemList
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineState
|
||||
@@ -25,13 +26,14 @@ import io.element.android.features.messages.impl.timeline.components.customreact
|
||||
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.voicemessages.aVoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||
@@ -47,6 +49,20 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||
roomAvatar = Async.Uninitialized,
|
||||
),
|
||||
aMessagesState().copy(composerState = aMessageComposerState().copy(showTextFormatting = true)),
|
||||
aMessagesState().copy(
|
||||
enableVoiceMessages = true,
|
||||
voiceMessageComposerState = aVoiceMessageComposerState(showPermissionRationaleDialog = true),
|
||||
),
|
||||
aMessagesState().copy(
|
||||
composerState = aMessageComposerState().copy(
|
||||
attachmentsState = AttachmentsState.Sending.Processing(persistentListOf())
|
||||
),
|
||||
),
|
||||
aMessagesState().copy(
|
||||
composerState = aMessageComposerState().copy(
|
||||
attachmentsState = AttachmentsState.Sending.Uploading(0.33f)
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,7 +75,7 @@ fun aMessagesState() = MessagesState(
|
||||
composerState = aMessageComposerState().copy(
|
||||
richTextEditorState = RichTextEditorState("Hello", initialFocus = true),
|
||||
isFullScreen = false,
|
||||
mode = MessageComposerMode.Normal("Hello"),
|
||||
mode = MessageComposerMode.Normal,
|
||||
),
|
||||
voiceMessageComposerState = aVoiceMessageComposerState(),
|
||||
timelineState = aTimelineState().copy(
|
||||
@@ -85,5 +101,7 @@ fun aMessagesState() = MessagesState(
|
||||
showReinvitePrompt = false,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
enableInRoomCalls = true,
|
||||
appName = "Element",
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -63,6 +63,8 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
|
||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
|
||||
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
|
||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
|
||||
@@ -76,10 +78,14 @@ import io.element.android.libraries.designsystem.components.dialogs.Confirmation
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
@@ -99,10 +105,15 @@ fun MessagesView(
|
||||
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
||||
onSendLocationClicked: () -> Unit,
|
||||
onCreatePollClicked: () -> Unit,
|
||||
onJoinCallClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||
|
||||
OnLifecycleEvent { _, event ->
|
||||
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
|
||||
}
|
||||
|
||||
AttachmentStateView(
|
||||
state = state.composerState.attachmentsState,
|
||||
onPreviewAttachments = onPreviewAttachments,
|
||||
@@ -160,8 +171,10 @@ fun MessagesView(
|
||||
MessagesViewTopBar(
|
||||
roomName = state.roomName.dataOrNull(),
|
||||
roomAvatar = state.roomAvatar.dataOrNull(),
|
||||
inRoomCallsEnabled = state.enableInRoomCalls,
|
||||
onBackPressed = onBackPressed,
|
||||
onRoomDetailsClicked = onRoomDetailsClicked,
|
||||
onJoinCallClicked = onJoinCallClicked,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -300,6 +313,18 @@ private fun MessagesViewContent(
|
||||
enableTextFormatting = state.enableTextFormatting,
|
||||
)
|
||||
|
||||
if (state.enableVoiceMessages && state.voiceMessageComposerState.showPermissionRationaleDialog) {
|
||||
VoiceMessagePermissionRationaleDialog(
|
||||
onContinue = {
|
||||
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale)
|
||||
},
|
||||
onDismiss = {
|
||||
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale)
|
||||
},
|
||||
appName = state.appName
|
||||
)
|
||||
}
|
||||
|
||||
ExpandableBottomSheetScaffold(
|
||||
sheetDragHandle = if (state.composerState.showTextFormatting) {
|
||||
@Composable { BottomSheetDragHandle() }
|
||||
@@ -349,8 +374,10 @@ private fun MessagesViewContent(
|
||||
private fun MessagesViewTopBar(
|
||||
roomName: String?,
|
||||
roomAvatar: AvatarData?,
|
||||
inRoomCallsEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onRoomDetailsClicked: () -> Unit = {},
|
||||
onJoinCallClicked: () -> Unit = {},
|
||||
onBackPressed: () -> Unit = {},
|
||||
) {
|
||||
TopAppBar(
|
||||
@@ -373,6 +400,13 @@ private fun MessagesViewTopBar(
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (inRoomCallsEnabled) {
|
||||
IconButton(onClick = onJoinCallClicked) {
|
||||
Icon(CommonDrawables.ic_compound_video_call, contentDescription = null) // TODO add proper content description once we have the state
|
||||
}
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets(0.dp)
|
||||
)
|
||||
}
|
||||
@@ -432,5 +466,6 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
|
||||
onUserDataClicked = {},
|
||||
onSendLocationClicked = {},
|
||||
onCreatePollClicked = {},
|
||||
onJoinCallClicked = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -348,7 +348,7 @@ private fun EmojiReactionsRow(
|
||||
) {
|
||||
// TODO use most recently used emojis here when available from the Rust SDK
|
||||
val defaultEmojis = sequenceOf(
|
||||
"👍", "👎", "🔥", "❤️", "👏"
|
||||
"👍️", "👎️", "🔥", "❤️", "👏"
|
||||
)
|
||||
for (emoji in defaultEmojis) {
|
||||
val isHighlighted = highlightedEmojis.contains(emoji)
|
||||
|
||||
@@ -29,6 +29,6 @@ import javax.inject.Inject
|
||||
@SingleIn(RoomScope::class)
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class MessageComposerContextImpl @Inject constructor() : MessageComposerContext {
|
||||
override var composerMode: MessageComposerMode by mutableStateOf(MessageComposerMode.Normal(""))
|
||||
override var composerMode: MessageComposerMode by mutableStateOf(MessageComposerMode.Normal)
|
||||
internal set
|
||||
}
|
||||
|
||||
@@ -155,10 +155,12 @@ class MessageComposerPresenter @Inject constructor(
|
||||
when (event) {
|
||||
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
||||
MessageComposerEvents.CloseSpecialMode -> {
|
||||
localCoroutineScope.launch {
|
||||
richTextEditorState.setHtml("")
|
||||
if (messageComposerContext.composerMode is MessageComposerMode.Edit) {
|
||||
localCoroutineScope.launch {
|
||||
richTextEditorState.setHtml("")
|
||||
}
|
||||
}
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal("")
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
}
|
||||
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(
|
||||
message = event.message,
|
||||
@@ -253,7 +255,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||
val capturedMode = messageComposerContext.composerMode
|
||||
// Reset composer right away
|
||||
richTextEditorState.setHtml("")
|
||||
updateComposerMode(MessageComposerMode.Normal(""))
|
||||
updateComposerMode(MessageComposerMode.Normal)
|
||||
when (capturedMode) {
|
||||
is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html)
|
||||
is MessageComposerMode.Edit -> {
|
||||
|
||||
@@ -30,7 +30,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
|
||||
fun aMessageComposerState(
|
||||
composerState: RichTextEditorState = RichTextEditorState(""),
|
||||
isFullScreen: Boolean = false,
|
||||
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
|
||||
mode: MessageComposerMode = MessageComposerMode.Normal,
|
||||
showTextFormatting: Boolean = false,
|
||||
showAttachmentSourcePicker: Boolean = false,
|
||||
canShareLocation: Boolean = true,
|
||||
|
||||
@@ -24,10 +24,10 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerStateProvider
|
||||
import io.element.android.features.messages.impl.voicemessages.aVoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerStateProvider
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
@@ -71,10 +71,14 @@ internal fun MessageComposerView(
|
||||
}
|
||||
}
|
||||
|
||||
fun onVoiceRecordButtonEvent(press: PressEvent) {
|
||||
val onVoiceRecordButtonEvent = { press: PressEvent ->
|
||||
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press))
|
||||
}
|
||||
|
||||
fun onSendVoiceMessage() {
|
||||
voiceMessageState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
}
|
||||
|
||||
TextComposer(
|
||||
modifier = modifier,
|
||||
state = state.richTextEditorState,
|
||||
@@ -89,7 +93,8 @@ internal fun MessageComposerView(
|
||||
onDismissTextFormatting = ::onDismissTextFormatting,
|
||||
enableTextFormatting = enableTextFormatting,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
onVoiceRecordButtonEvent = ::onVoiceRecordButtonEvent,
|
||||
onVoiceRecordButtonEvent = onVoiceRecordButtonEvent,
|
||||
onSendVoiceMessage = ::onSendVoiceMessage,
|
||||
onError = ::onError,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ fun aTimelineItemReactions(
|
||||
count: Int = 1,
|
||||
isHighlighted: Boolean = false,
|
||||
): TimelineItemReactions {
|
||||
val emojis = arrayOf("👍", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️")
|
||||
val emojis = arrayOf("👍️", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️")
|
||||
return TimelineItemReactions(
|
||||
reactions = buildList {
|
||||
repeat(count) { index ->
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
@Composable
|
||||
fun EmojiItem(
|
||||
item: Emoji,
|
||||
isSelected: Boolean,
|
||||
onEmojiSelected: (Emoji) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val backgroundColor = if (isSelected) {
|
||||
ElementTheme.colors.bgActionPrimaryRest
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(40.dp)
|
||||
.background(backgroundColor, CircleShape)
|
||||
.clickable(
|
||||
enabled = true,
|
||||
onClick = { onEmojiSelected(item) },
|
||||
indication = rememberRipple(bounded = false, radius = 20.dp),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = item.unicode,
|
||||
style = ElementTheme.typography.fontHeadingSmRegular,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EmojiItemPreview() = ElementPreview {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for (isSelected in listOf(true, false)) {
|
||||
EmojiItem(
|
||||
item = Emoji(
|
||||
hexcode = "",
|
||||
label = "",
|
||||
tags = null,
|
||||
shortcodes = emptyList(),
|
||||
unicode = "👍",
|
||||
skins = null
|
||||
),
|
||||
isSelected = isSelected,
|
||||
onEmojiSelected = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,31 +17,22 @@
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.SecondaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -52,8 +43,6 @@ import io.element.android.emojibasebindings.EmojibaseStore
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -101,31 +90,12 @@ fun EmojiPicker(
|
||||
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
|
||||
items(emojis, key = { it.unicode }) { item ->
|
||||
val backgroundColor = if (selectedEmojis.contains(item.unicode)) {
|
||||
ElementTheme.colors.bgActionPrimaryRest
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(backgroundColor, CircleShape)
|
||||
.clickable(
|
||||
enabled = true,
|
||||
onClick = { onEmojiSelected(item) },
|
||||
indication = rememberRipple(bounded = false, radius = 20.dp),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = item.unicode,
|
||||
style = ElementTheme.typography.fontHeadingSmRegular,
|
||||
)
|
||||
}
|
||||
EmojiItem(
|
||||
item = item,
|
||||
isSelected = selectedEmojis.contains(item.unicode),
|
||||
onEmojiSelected = onEmojiSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
@@ -61,7 +62,7 @@ fun TimelineEncryptedHistoryBannerView(modifier: Modifier = Modifier) {
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun TimelineEncryptedHistoryBannerViewPreview() {
|
||||
ElementTheme {
|
||||
ElementPreview {
|
||||
TimelineEncryptedHistoryBannerView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageT
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||
@@ -131,8 +132,14 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
is OtherMessageType -> TimelineItemTextContent(
|
||||
body = messageType.body,
|
||||
htmlDocument = null,
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
UnknownMessageType -> TimelineItemTextContent(
|
||||
// Display the body as a fallback
|
||||
// Display the body as a fallback, but should not happen anymore
|
||||
// (we have `OtherMessageType` now)
|
||||
body = content.body,
|
||||
htmlDocument = null,
|
||||
isEdited = content.isEdited,
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import javax.inject.Inject
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
class VoiceMessageComposerPresenter @Inject constructor() : Presenter<VoiceMessageComposerState> {
|
||||
@Composable
|
||||
override fun present(): VoiceMessageComposerState {
|
||||
var voiceMessageState by remember { mutableStateOf<VoiceMessageState>(VoiceMessageState.Idle) }
|
||||
|
||||
fun onRecordButtonPress(event: VoiceMessageComposerEvents.RecordButtonEvent) = when(event.pressEvent) {
|
||||
PressEvent.PressStart -> {
|
||||
// TODO start the recording
|
||||
voiceMessageState = VoiceMessageState.Recording
|
||||
}
|
||||
PressEvent.LongPressEnd -> {
|
||||
// TODO finish the recording
|
||||
voiceMessageState = VoiceMessageState.Idle
|
||||
}
|
||||
PressEvent.Tapped -> {
|
||||
// TODO discard the recording and show the 'hold to record' tooltip
|
||||
voiceMessageState = VoiceMessageState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun handleEvents(event: VoiceMessageComposerEvents) {
|
||||
when (event) {
|
||||
is VoiceMessageComposerEvents.RecordButtonEvent -> onRecordButtonPress(event)
|
||||
}
|
||||
}
|
||||
|
||||
return VoiceMessageComposerState(
|
||||
voiceMessageState = voiceMessageState,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages
|
||||
|
||||
internal sealed class VoiceMessageException : Exception() {
|
||||
data class FileException(
|
||||
override val message: String?, override val cause: Throwable? = null
|
||||
) : VoiceMessageException()
|
||||
data class PermissionMissing(
|
||||
override val message: String?, override val cause: Throwable?
|
||||
) : VoiceMessageException()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.composer
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
|
||||
sealed interface VoiceMessageComposerEvents {
|
||||
data class RecordButtonEvent(
|
||||
val pressEvent: PressEvent
|
||||
): VoiceMessageComposerEvents
|
||||
data object SendVoiceMessage: VoiceMessageComposerEvents
|
||||
data object AcceptPermissionRationale: VoiceMessageComposerEvents
|
||||
data object DismissPermissionsRationale: VoiceMessageComposerEvents
|
||||
data class LifecycleEvent(val event: Lifecycle.Event): VoiceMessageComposerEvents
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.composer
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
|
||||
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
class VoiceMessageComposerPresenter @Inject constructor(
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val voiceRecorder: VoiceRecorder,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val mediaSender: MediaSender,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory
|
||||
) : Presenter<VoiceMessageComposerState> {
|
||||
private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO)
|
||||
|
||||
@Composable
|
||||
override fun present(): VoiceMessageComposerState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle)
|
||||
|
||||
val permissionState = permissionsPresenter.present()
|
||||
var isSending by remember { mutableStateOf(false) }
|
||||
|
||||
val onLifecycleEvent = { event: Lifecycle.Event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
appCoroutineScope.finishRecording()
|
||||
}
|
||||
Lifecycle.Event.ON_DESTROY -> {
|
||||
appCoroutineScope.cancelRecording()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
val onRecordButtonPress = { event: VoiceMessageComposerEvents.RecordButtonEvent ->
|
||||
val permissionGranted = permissionState.permissionGranted
|
||||
when (event.pressEvent) {
|
||||
PressEvent.PressStart -> {
|
||||
Timber.v("Voice message record button pressed")
|
||||
when {
|
||||
permissionGranted -> {
|
||||
localCoroutineScope.startRecording()
|
||||
}
|
||||
else -> {
|
||||
Timber.i("Voice message permission needed")
|
||||
permissionState.eventSink(PermissionsEvents.RequestPermissions)
|
||||
}
|
||||
}
|
||||
}
|
||||
PressEvent.LongPressEnd -> {
|
||||
Timber.v("Voice message record button released")
|
||||
localCoroutineScope.finishRecording()
|
||||
}
|
||||
PressEvent.Tapped -> {
|
||||
Timber.v("Voice message record button tapped")
|
||||
localCoroutineScope.cancelRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onAcceptPermissionsRationale = {
|
||||
permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
|
||||
}
|
||||
|
||||
val onDismissPermissionsRationale = {
|
||||
permissionState.eventSink(PermissionsEvents.CloseDialog)
|
||||
}
|
||||
|
||||
val onSendButtonPress = lambda@{
|
||||
val finishedState = recorderState as? VoiceRecorderState.Finished
|
||||
if (finishedState == null) {
|
||||
val exception = VoiceMessageException.FileException("No file to send")
|
||||
analyticsService.trackError(exception)
|
||||
Timber.e(exception)
|
||||
return@lambda
|
||||
}
|
||||
if (isSending) {
|
||||
return@lambda
|
||||
}
|
||||
isSending = true
|
||||
appCoroutineScope.sendMessage(
|
||||
file = finishedState.file,
|
||||
mimeType = finishedState.mimeType,
|
||||
).invokeOnCompletion {
|
||||
isSending = false
|
||||
}
|
||||
}
|
||||
|
||||
val handleEvents: (VoiceMessageComposerEvents) -> Unit = { event ->
|
||||
when (event) {
|
||||
is VoiceMessageComposerEvents.RecordButtonEvent -> onRecordButtonPress(event)
|
||||
is VoiceMessageComposerEvents.SendVoiceMessage -> localCoroutineScope.launch {
|
||||
onSendButtonPress()
|
||||
}
|
||||
VoiceMessageComposerEvents.DismissPermissionsRationale -> onDismissPermissionsRationale()
|
||||
VoiceMessageComposerEvents.AcceptPermissionRationale -> onAcceptPermissionsRationale()
|
||||
is VoiceMessageComposerEvents.LifecycleEvent -> onLifecycleEvent(event.event)
|
||||
}
|
||||
}
|
||||
|
||||
return VoiceMessageComposerState(
|
||||
voiceMessageState = when (val state = recorderState) {
|
||||
is VoiceRecorderState.Recording -> VoiceMessageState.Recording(
|
||||
duration = state.elapsedTime,
|
||||
level = state.level
|
||||
)
|
||||
is VoiceRecorderState.Finished -> if (isSending) {
|
||||
VoiceMessageState.Sending
|
||||
} else {
|
||||
VoiceMessageState.Preview
|
||||
}
|
||||
else -> VoiceMessageState.Idle
|
||||
},
|
||||
showPermissionRationaleDialog = permissionState.showDialog,
|
||||
eventSink = handleEvents,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.startRecording() = launch {
|
||||
try {
|
||||
voiceRecorder.startRecord()
|
||||
} catch (e: SecurityException) {
|
||||
Timber.e(e, "Voice message error")
|
||||
analyticsService.trackError(VoiceMessageException.PermissionMissing("Expected permission to record but none", e))
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.finishRecording() = launch {
|
||||
voiceRecorder.stopRecord()
|
||||
}
|
||||
|
||||
private fun CoroutineScope.cancelRecording() = launch {
|
||||
voiceRecorder.stopRecord(cancelled = true)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.sendMessage(
|
||||
file: File, mimeType: String,
|
||||
) = launch {
|
||||
val result = mediaSender.sendVoiceMessage(
|
||||
uri = file.toUri(),
|
||||
mimeType = mimeType,
|
||||
waveForm = emptyList(), // TODO generate waveform
|
||||
)
|
||||
|
||||
if (result.isFailure) {
|
||||
Timber.e(result.exceptionOrNull(), "Voice message error")
|
||||
return@launch
|
||||
}
|
||||
|
||||
voiceRecorder.deleteRecording()
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages
|
||||
package io.element.android.features.messages.impl.voicemessages.composer
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
@@ -22,6 +22,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
@Stable
|
||||
data class VoiceMessageComposerState(
|
||||
val voiceMessageState: VoiceMessageState,
|
||||
val showPermissionRationaleDialog: Boolean,
|
||||
val eventSink: (VoiceMessageComposerEvents) -> Unit,
|
||||
)
|
||||
|
||||
@@ -14,21 +14,24 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages
|
||||
package io.element.android.features.messages.impl.voicemessages.composer
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
|
||||
override val values: Sequence<VoiceMessageComposerState>
|
||||
get() = sequenceOf(
|
||||
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording),
|
||||
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, level = 0.5)),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aVoiceMessageComposerState(
|
||||
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
|
||||
showPermissionRationaleDialog: Boolean = false,
|
||||
) = VoiceMessageComposerState(
|
||||
voiceMessageState = voiceMessageState,
|
||||
showPermissionRationaleDialog = showPermissionRationaleDialog,
|
||||
eventSink = {},
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.composer
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
internal fun VoiceMessagePermissionRationaleDialog(
|
||||
onContinue: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
appName: String,
|
||||
) {
|
||||
ConfirmationDialog(
|
||||
content = stringResource(CommonStrings.error_missing_microphone_voice_rationale_android, appName),
|
||||
onSubmitClicked = onContinue,
|
||||
onDismiss = onDismiss,
|
||||
submitText = stringResource(CommonStrings.action_continue),
|
||||
cancelText = stringResource(CommonStrings.action_cancel),
|
||||
)
|
||||
}
|
||||
@@ -38,5 +38,6 @@
|
||||
<string name="screen_room_retry_send_menu_title">"Не удалось отправить ваше сообщение"</string>
|
||||
<string name="screen_room_timeline_add_reaction">"Добавить эмодзи"</string>
|
||||
<string name="screen_room_timeline_less_reactions">"Показать меньше"</string>
|
||||
<string name="screen_room_voice_message_tooltip">"Удерживайте для записи"</string>
|
||||
<string name="screen_room_error_failed_processing_media">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
|
||||
</resources>
|
||||
|
||||
@@ -40,7 +40,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||
import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory
|
||||
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
|
||||
@@ -66,6 +66,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
@@ -75,6 +76,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
@@ -607,20 +609,28 @@ class MessagesPresenterTest {
|
||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||
): MessagesPresenter {
|
||||
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
|
||||
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
|
||||
val messageComposerPresenter = MessageComposerPresenter(
|
||||
appCoroutineScope = this,
|
||||
room = matrixRoom,
|
||||
mediaPickerProvider = FakePickerProvider(),
|
||||
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)),
|
||||
localMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
|
||||
mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom),
|
||||
mediaSender = mediaSender,
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
analyticsService = analyticsService,
|
||||
messageComposerContext = MessageComposerContextImpl(),
|
||||
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
||||
permissionsPresenterFactory = permissionsPresenterFactory,
|
||||
)
|
||||
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
|
||||
this,
|
||||
FakeVoiceRecorder(),
|
||||
analyticsService,
|
||||
mediaSender,
|
||||
permissionsPresenterFactory,
|
||||
)
|
||||
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter()
|
||||
val timelinePresenter = TimelinePresenter(
|
||||
timelineItemsFactory = aTimelineItemsFactory(),
|
||||
room = matrixRoom,
|
||||
@@ -649,6 +659,7 @@ class MessagesPresenterTest {
|
||||
clipboardHelper = clipboardHelper,
|
||||
preferencesStore = preferencesStore,
|
||||
featureFlagsService = FakeFeatureFlagService(),
|
||||
buildMeta = aBuildMeta(),
|
||||
dispatchers = coroutineDispatchers,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ class MessageComposerPresenterTest {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isFullScreen).isFalse()
|
||||
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
|
||||
assertThat(initialState.showAttachmentSourcePicker).isFalse()
|
||||
assertThat(initialState.canShareLocation).isTrue()
|
||||
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
|
||||
@@ -153,7 +153,10 @@ class MessageComposerPresenterTest {
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
state = awaitItem()
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
|
||||
backToNormalMode(state, skipCount = 1)
|
||||
state = backToNormalMode(state, skipCount = 1)
|
||||
|
||||
// The message that was being edited is cleared
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +177,26 @@ class MessageComposerPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - cancel reply`() = runTest {
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
var state = awaitItem()
|
||||
val mode = aReplyMode()
|
||||
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(mode)
|
||||
state.richTextEditorState.setHtml(A_REPLY)
|
||||
state = backToNormalMode(state)
|
||||
|
||||
// The message typed while replying is not cleared
|
||||
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_REPLY)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - change mode to quote`() = runTest {
|
||||
val presenter = createPresenter(this)
|
||||
@@ -683,12 +706,12 @@ class MessageComposerPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
|
||||
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0): MessageComposerState {
|
||||
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
|
||||
skipItems(skipCount)
|
||||
val normalState = awaitItem()
|
||||
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||
assertThat(normalState.richTextEditorState.messageHtml).isEqualTo("")
|
||||
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal)
|
||||
return normalState
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user