Merge branch 'develop' into feature/fga/pin_auth_ui
This commit is contained in:
commit
11adae6cd0
135 changed files with 2870 additions and 242 deletions
|
|
@ -8,8 +8,6 @@ appId: ${APP_ID}
|
||||||
# Back from timeline
|
# Back from timeline
|
||||||
- back
|
- back
|
||||||
- assertVisible: "MyR"
|
- assertVisible: "MyR"
|
||||||
# Close keyboard
|
|
||||||
- hideKeyboard
|
|
||||||
# Back from search
|
# Back from search
|
||||||
- back
|
- back
|
||||||
- runFlow: ../assertions/assertHomeDisplayed.yaml
|
- 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.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.services.toolbox.api.appname
|
package io.element.android.appconfig
|
||||||
|
|
||||||
interface AppNameProvider {
|
object ElementCallConfig {
|
||||||
fun getAppName(): String
|
const val DEFAULT_BASE_URL = "https://call.element.io"
|
||||||
}
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.api.config
|
package io.element.android.appconfig
|
||||||
|
|
||||||
object MatrixConfiguration {
|
object MatrixConfiguration {
|
||||||
const val matrixToPermalinkBaseUrl: String = "https://matrix.to/#/"
|
const val matrixToPermalinkBaseUrl: String = "https://matrix.to/#/"
|
||||||
|
|
@ -253,7 +253,6 @@ koverMerged {
|
||||||
// Temporary until we have actually something to test.
|
// 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.auth.PinAuthenticationPresenter$*"
|
excludes += "io.element.android.features.lockscreen.impl.auth.PinAuthenticationPresenter$*"
|
||||||
excludes += "io.element.android.features.lockscreen.impl.create.CreatePinPresenter"
|
|
||||||
}
|
}
|
||||||
bound {
|
bound {
|
||||||
minValue = 85
|
minValue = 85
|
||||||
|
|
|
||||||
|
|
@ -18,20 +18,44 @@ plugins {
|
||||||
id("io.element.android-compose-library")
|
id("io.element.android-compose-library")
|
||||||
alias(libs.plugins.anvil)
|
alias(libs.plugins.anvil)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
|
id("kotlin-parcelize")
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.features.call"
|
namespace = "io.element.android.features.call"
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
anvil {
|
||||||
|
generateDaggerFactories.set(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation(projects.appnav)
|
||||||
|
implementation(projects.appconfig)
|
||||||
|
implementation(projects.anvilannotations)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
|
implementation(projects.libraries.matrix.impl)
|
||||||
implementation(projects.libraries.network)
|
implementation(projects.libraries.network)
|
||||||
|
implementation(projects.libraries.preferences.api)
|
||||||
|
implementation(projects.services.toolbox.api)
|
||||||
implementation(libs.androidx.webkit)
|
implementation(libs.androidx.webkit)
|
||||||
|
implementation(libs.serialization.json)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.coroutines.test)
|
||||||
|
testImplementation(libs.molecule.runtime)
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
|
testImplementation(libs.test.turbine)
|
||||||
testImplementation(libs.test.robolectric)
|
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>
|
<application>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ElementCallActivity"
|
android:name=".ui.ElementCallActivity"
|
||||||
android:label="@string/element_call"
|
android:label="@string/element_call"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:taskAffinity="io.element.android.features.call"
|
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.NotificationManagerCompat
|
||||||
import androidx.core.app.PendingIntentCompat
|
import androidx.core.app.PendingIntentCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import io.element.android.features.call.ui.ElementCallActivity
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
|
|
||||||
class CallForegroundService : Service() {
|
class CallForegroundService : Service() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
sealed interface CallType : NodeInputs, Parcelable {
|
||||||
|
@Parcelize
|
||||||
|
data class ExternalUrl(val url: String) : CallType
|
||||||
|
|
||||||
|
@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
|
package io.element.android.features.call.di
|
||||||
|
|
||||||
import com.squareup.anvil.annotations.ContributesTo
|
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
|
import io.element.android.libraries.di.AppScope
|
||||||
|
|
||||||
@ContributesTo(AppScope::class)
|
@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.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call
|
package io.element.android.features.call.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.webkit.PermissionRequest
|
import android.webkit.PermissionRequest
|
||||||
import android.webkit.WebChromeClient
|
import android.webkit.WebChromeClient
|
||||||
import android.webkit.WebView
|
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.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
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.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.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
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.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
import io.element.android.libraries.theme.ElementTheme
|
|
||||||
|
|
||||||
typealias RequestPermissionCallback = (Array<String>) -> Unit
|
typealias RequestPermissionCallback = (Array<String>) -> Unit
|
||||||
|
|
||||||
|
interface CallScreenNavigator {
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CallScreenView(
|
internal fun CallScreenView(
|
||||||
url: String?,
|
state: CallScreenState,
|
||||||
userAgent: String,
|
|
||||||
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
|
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
|
||||||
onClose: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
ElementTheme {
|
Scaffold(
|
||||||
Scaffold(
|
modifier = modifier,
|
||||||
modifier = modifier,
|
topBar = {
|
||||||
topBar = {
|
TopAppBar(
|
||||||
TopAppBar(
|
title = { Text(stringResource(R.string.element_call)) },
|
||||||
title = { Text(stringResource(R.string.element_call)) },
|
navigationIcon = {
|
||||||
navigationIcon = {
|
BackButton(
|
||||||
BackButton(
|
resourceId = CommonDrawables.ic_compound_close,
|
||||||
resourceId = CommonDrawables.ic_compound_close,
|
onClick = { state.eventSink(CallScreeEvents.Hangup) }
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
) { 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
|
@Composable
|
||||||
private fun CallWebView(
|
private fun CallWebView(
|
||||||
url: String?,
|
url: Async<String>,
|
||||||
userAgent: String,
|
userAgent: String,
|
||||||
onPermissionsRequested: (PermissionRequest) -> Unit,
|
onPermissionsRequested: (PermissionRequest) -> Unit,
|
||||||
|
onWebViewCreated: (WebView) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val isInpectionMode = LocalInspectionMode.current
|
if (LocalInspectionMode.current) {
|
||||||
AndroidView(
|
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||||
modifier = modifier,
|
Text("WebView - can't be previewed")
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
)
|
} 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")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
private fun WebView.setup(userAgent: String, onPermissionsRequested: (PermissionRequest) -> Unit) {
|
private fun WebView.setup(
|
||||||
|
userAgent: String,
|
||||||
|
onPermissionsRequested: (PermissionRequest) -> Unit,
|
||||||
|
) {
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
|
@ -140,12 +162,15 @@ private fun WebView.setup(userAgent: String, onPermissionsRequested: (Permission
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CallScreenViewPreview() {
|
internal fun CallScreenViewPreview() {
|
||||||
ElementTheme {
|
ElementPreview {
|
||||||
CallScreenView(
|
CallScreenView(
|
||||||
url = "https://call.element.io/some-actual-call?with=parameters",
|
state = CallScreenState(
|
||||||
userAgent = "",
|
urlState = Async.Success("https://call.element.io/some-actual-call?with=parameters"),
|
||||||
|
isInWidgetMode = false,
|
||||||
|
userAgent = "",
|
||||||
|
eventSink = {},
|
||||||
|
),
|
||||||
requestPermissions = { _, _ -> },
|
requestPermissions = { _, _ -> },
|
||||||
onClose = { },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -14,10 +14,12 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call
|
package io.element.android.features.call.ui
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.AudioFocusRequest
|
import android.media.AudioFocusRequest
|
||||||
|
|
@ -26,20 +28,40 @@ import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.webkit.PermissionRequest
|
import android.webkit.PermissionRequest
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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.di.CallBindings
|
||||||
|
import io.element.android.features.call.utils.CallIntentDataParser
|
||||||
import io.element.android.libraries.architecture.bindings
|
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
|
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 callIntentDataParser: CallIntentDataParser
|
||||||
|
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
|
||||||
|
|
||||||
|
private lateinit var presenter: CallScreenPresenter
|
||||||
|
|
||||||
private lateinit var audioManager: AudioManager
|
private lateinit var audioManager: AudioManager
|
||||||
|
|
||||||
|
|
@ -51,7 +73,7 @@ class ElementCallActivity : ComponentActivity() {
|
||||||
private val requestPermissionsLauncher = registerPermissionResultLauncher()
|
private val requestPermissionsLauncher = registerPermissionResultLauncher()
|
||||||
|
|
||||||
private var isDarkMode = false
|
private var isDarkMode = false
|
||||||
private val urlState = mutableStateOf<String?>(null)
|
private val webViewTarget = mutableStateOf<CallType?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
@ -60,10 +82,7 @@ class ElementCallActivity : ComponentActivity() {
|
||||||
|
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
|
||||||
urlState.value = intent?.dataString?.let(::parseUrl) ?: run {
|
setCallType(intent)
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
updateUiMode(resources.configuration)
|
updateUiMode(resources.configuration)
|
||||||
|
|
@ -72,18 +91,17 @@ class ElementCallActivity : ComponentActivity() {
|
||||||
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
||||||
requestAudioFocus()
|
requestAudioFocus()
|
||||||
|
|
||||||
val userAgent = userAgentProvider.provide()
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
CallScreenView(
|
val state = presenter.present()
|
||||||
url = urlState.value,
|
ElementTheme {
|
||||||
userAgent = userAgent,
|
CallScreenView(
|
||||||
onClose = this::finish,
|
state = state,
|
||||||
requestPermissions = { permissions, callback ->
|
requestPermissions = { permissions, callback ->
|
||||||
requestPermissionCallback = callback
|
requestPermissionCallback = callback
|
||||||
requestPermissionsLauncher.launch(permissions)
|
requestPermissionsLauncher.launch(permissions)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,15 +114,7 @@ class ElementCallActivity : ComponentActivity() {
|
||||||
override fun onNewIntent(intent: Intent?) {
|
override fun onNewIntent(intent: Intent?) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
|
|
||||||
val intentUrl = intent?.dataString?.let(::parseUrl)
|
setCallType(intent)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
|
|
@ -130,6 +140,32 @@ class ElementCallActivity : ComponentActivity() {
|
||||||
finishAndRemoveTask()
|
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 parseUrl(url: String?): String? = callIntentDataParser.parse(url)
|
||||||
|
|
||||||
private fun registerPermissionResultLauncher(): ActivityResultLauncher<Array<String>> {
|
private fun registerPermissionResultLauncher(): ActivityResultLauncher<Array<String>> {
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call
|
package io.element.android.features.call.utils
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
interface CallWidgetProvider {
|
||||||
|
suspend fun getWidget(
|
||||||
|
sessionId: SessionId,
|
||||||
|
roomId: RoomId,
|
||||||
|
clientId: String,
|
||||||
|
languageTag: String? = null,
|
||||||
|
theme: String? = null,
|
||||||
|
): Result<Pair<MatrixWidgetDriver, String>>
|
||||||
|
}
|
||||||
|
|
@ -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.Manifest
|
||||||
import android.webkit.PermissionRequest
|
import android.webkit.PermissionRequest
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.call.ui.mapWebkitPermissions
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class MapWebkitPermissionsTest {
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
class FakeCallScreenNavigator : CallScreenNavigator {
|
||||||
|
var closeCalled = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
closeCalled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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 com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.Test
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,7 @@ dependencies {
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.tests.testutils)
|
||||||
testImplementation(projects.libraries.cryptography.test)
|
testImplementation(projects.libraries.cryptography.test)
|
||||||
testImplementation(projects.libraries.cryptography.impl)
|
testImplementation(projects.libraries.cryptography.impl)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,5 +17,6 @@
|
||||||
package io.element.android.features.lockscreen.impl.create
|
package io.element.android.features.lockscreen.impl.create
|
||||||
|
|
||||||
sealed interface CreatePinEvents {
|
sealed interface CreatePinEvents {
|
||||||
object MyEvent : CreatePinEvents
|
data class OnPinEntryChanged(val entryAsText: String) : CreatePinEvents
|
||||||
|
data object ClearFailure : CreatePinEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ class CreatePinNode @AssistedInject constructor(
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
CreatePinView(
|
CreatePinView(
|
||||||
state = state,
|
state = state,
|
||||||
|
onBackClicked = { },
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,21 +17,86 @@
|
||||||
package io.element.android.features.lockscreen.impl.create
|
package io.element.android.features.lockscreen.impl.create
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
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.features.lockscreen.impl.create.model.PinEntry
|
||||||
|
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
|
||||||
|
import io.element.android.features.lockscreen.impl.create.validation.PinValidator
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class CreatePinPresenter @Inject constructor() : Presenter<CreatePinState> {
|
private const val PIN_SIZE = 4
|
||||||
|
|
||||||
|
class CreatePinPresenter @Inject constructor(
|
||||||
|
private val pinValidator: PinValidator,
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
) : Presenter<CreatePinState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): CreatePinState {
|
override fun present(): CreatePinState {
|
||||||
|
var choosePinEntry by remember {
|
||||||
|
mutableStateOf(PinEntry.empty(PIN_SIZE))
|
||||||
|
}
|
||||||
|
var confirmPinEntry by remember {
|
||||||
|
mutableStateOf(PinEntry.empty(PIN_SIZE))
|
||||||
|
}
|
||||||
|
var isConfirmationStep by remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
var createPinFailure by remember {
|
||||||
|
mutableStateOf<CreatePinFailure?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvents(event: CreatePinEvents) {
|
fun handleEvents(event: CreatePinEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
CreatePinEvents.MyEvent -> Unit
|
is CreatePinEvents.OnPinEntryChanged -> {
|
||||||
|
if (isConfirmationStep) {
|
||||||
|
confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText)
|
||||||
|
if (confirmPinEntry.isPinComplete()) {
|
||||||
|
if (confirmPinEntry == choosePinEntry) {
|
||||||
|
//TODO save in db and navigate to next screen
|
||||||
|
} else {
|
||||||
|
createPinFailure = CreatePinFailure.PinsDontMatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
choosePinEntry = choosePinEntry.fillWith(event.entryAsText)
|
||||||
|
if (choosePinEntry.isPinComplete()) {
|
||||||
|
when (val pinValidationResult = pinValidator.isPinValid(choosePinEntry)) {
|
||||||
|
is PinValidator.Result.Invalid -> {
|
||||||
|
createPinFailure = pinValidationResult.failure
|
||||||
|
}
|
||||||
|
PinValidator.Result.Valid -> isConfirmationStep = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CreatePinEvents.ClearFailure -> {
|
||||||
|
when (createPinFailure) {
|
||||||
|
is CreatePinFailure.PinsDontMatch -> {
|
||||||
|
choosePinEntry = PinEntry.empty(PIN_SIZE)
|
||||||
|
confirmPinEntry = PinEntry.empty(PIN_SIZE)
|
||||||
|
}
|
||||||
|
is CreatePinFailure.PinBlacklisted -> {
|
||||||
|
choosePinEntry = PinEntry.empty(PIN_SIZE)
|
||||||
|
}
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
isConfirmationStep = false
|
||||||
|
createPinFailure = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return CreatePinState(
|
return CreatePinState(
|
||||||
|
choosePinEntry = choosePinEntry,
|
||||||
|
confirmPinEntry = confirmPinEntry,
|
||||||
|
isConfirmationStep = isConfirmationStep,
|
||||||
|
createPinFailure = createPinFailure,
|
||||||
|
appName = buildMeta.applicationName,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,21 @@
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.create
|
package io.element.android.features.lockscreen.impl.create
|
||||||
|
|
||||||
|
import io.element.android.features.lockscreen.impl.create.model.PinEntry
|
||||||
|
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
|
||||||
|
|
||||||
data class CreatePinState(
|
data class CreatePinState(
|
||||||
|
val choosePinEntry: PinEntry,
|
||||||
|
val confirmPinEntry: PinEntry,
|
||||||
|
val isConfirmationStep: Boolean,
|
||||||
|
val createPinFailure: CreatePinFailure?,
|
||||||
|
val appName: String,
|
||||||
val eventSink: (CreatePinEvents) -> Unit
|
val eventSink: (CreatePinEvents) -> Unit
|
||||||
)
|
) {
|
||||||
|
val pinSize = choosePinEntry.size
|
||||||
|
val activePinEntry = if (isConfirmationStep) {
|
||||||
|
confirmPinEntry
|
||||||
|
} else {
|
||||||
|
choosePinEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,15 +17,45 @@
|
||||||
package io.element.android.features.lockscreen.impl.create
|
package io.element.android.features.lockscreen.impl.create
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.features.lockscreen.impl.create.model.PinEntry
|
||||||
|
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
|
||||||
|
|
||||||
open class CreatePinStateProvider : PreviewParameterProvider<CreatePinState> {
|
open class CreatePinStateProvider : PreviewParameterProvider<CreatePinState> {
|
||||||
override val values: Sequence<CreatePinState>
|
override val values: Sequence<CreatePinState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aCreatePinState(),
|
aCreatePinState(),
|
||||||
// Add other states here
|
aCreatePinState(
|
||||||
|
choosePinEntry = PinEntry.empty(4).fillWith("12")
|
||||||
|
),
|
||||||
|
aCreatePinState(
|
||||||
|
choosePinEntry = PinEntry.empty(4).fillWith("1789"),
|
||||||
|
isConfirmationStep = true,
|
||||||
|
),
|
||||||
|
aCreatePinState(
|
||||||
|
choosePinEntry = PinEntry.empty(4).fillWith("1789"),
|
||||||
|
confirmPinEntry = PinEntry.empty(4).fillWith("1788"),
|
||||||
|
isConfirmationStep = true,
|
||||||
|
creationFailure = CreatePinFailure.PinsDontMatch
|
||||||
|
),
|
||||||
|
aCreatePinState(
|
||||||
|
choosePinEntry = PinEntry.empty(4).fillWith("1111"),
|
||||||
|
creationFailure = CreatePinFailure.PinBlacklisted
|
||||||
|
),
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aCreatePinState() = CreatePinState(
|
fun aCreatePinState(
|
||||||
|
choosePinEntry: PinEntry = PinEntry.empty(4),
|
||||||
|
confirmPinEntry: PinEntry = PinEntry.empty(4),
|
||||||
|
isConfirmationStep: Boolean = false,
|
||||||
|
creationFailure: CreatePinFailure? = null,
|
||||||
|
) = CreatePinState(
|
||||||
|
choosePinEntry = choosePinEntry,
|
||||||
|
confirmPinEntry = confirmPinEntry,
|
||||||
|
isConfirmationStep = isConfirmationStep,
|
||||||
|
createPinFailure = creationFailure,
|
||||||
|
appName = "Element",
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,39 +14,222 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.create
|
package io.element.android.features.lockscreen.impl.create
|
||||||
|
|
||||||
|
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.Box
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
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.create.model.PinDigit
|
||||||
|
import io.element.android.features.lockscreen.impl.create.model.PinEntry
|
||||||
|
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
|
||||||
|
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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.Text
|
||||||
import timber.log.Timber
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
|
import io.element.android.libraries.designsystem.theme.pinDigitBg
|
||||||
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CreatePinView(
|
fun CreatePinView(
|
||||||
state: CreatePinState,
|
state: CreatePinState,
|
||||||
|
onBackClicked: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Timber.d("CreatePinView: $state")
|
Scaffold(
|
||||||
Box(modifier, contentAlignment = Alignment.Center) {
|
modifier = modifier,
|
||||||
Text(
|
topBar = {
|
||||||
"CreatePin feature view",
|
TopAppBar(
|
||||||
color = MaterialTheme.colorScheme.primary,
|
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),
|
||||||
|
) {
|
||||||
|
CreatePinHeader(state.isConfirmationStep, state.appName)
|
||||||
|
CreatePinContent(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CreatePinHeader(
|
||||||
|
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 CreatePinContent(
|
||||||
|
state: CreatePinState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
PinEntryTextField(
|
||||||
|
state.activePinEntry,
|
||||||
|
onValueChange = {
|
||||||
|
state.eventSink(CreatePinEvents.OnPinEntryChanged(it))
|
||||||
|
},
|
||||||
|
modifier = modifier
|
||||||
|
.padding(top = 36.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
if (state.createPinFailure != null) {
|
||||||
|
ErrorDialog(
|
||||||
|
modifier = modifier,
|
||||||
|
title = state.createPinFailure.title(),
|
||||||
|
content = state.createPinFailure.content(),
|
||||||
|
onDismiss = {
|
||||||
|
state.eventSink(CreatePinEvents.ClearFailure)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CreatePinFailure.content(): String {
|
||||||
|
return when (this) {
|
||||||
|
CreatePinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_content)
|
||||||
|
CreatePinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CreatePinFailure.title(): String {
|
||||||
|
return when (this) {
|
||||||
|
CreatePinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_title)
|
||||||
|
CreatePinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
internal fun CreatePinViewPreview(@PreviewParameter(CreatePinStateProvider::class) state: CreatePinState) {
|
internal fun CreatePinViewPreview(@PreviewParameter(CreatePinStateProvider::class) state: CreatePinState) {
|
||||||
ElementPreview {
|
ElementPreview {
|
||||||
CreatePinView(
|
CreatePinView(
|
||||||
state = state,
|
state = state,
|
||||||
|
onBackClicked = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.create.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,66 @@
|
||||||
|
/*
|
||||||
|
* 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.model
|
||||||
|
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
|
||||||
|
data class PinEntry(
|
||||||
|
val digits: ImmutableList<PinDigit>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun empty(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 clear(): PinEntry {
|
||||||
|
return fillWith("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPinComplete(): Boolean {
|
||||||
|
return digits.all { it is PinDigit.Filled }
|
||||||
|
}
|
||||||
|
|
||||||
|
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.create.validation
|
||||||
|
|
||||||
|
sealed interface CreatePinFailure {
|
||||||
|
data object PinBlacklisted : CreatePinFailure
|
||||||
|
data object PinsDontMatch : CreatePinFailure
|
||||||
|
}
|
||||||
|
|
@ -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.create.validation
|
||||||
|
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import io.element.android.features.lockscreen.impl.create.model.PinEntry
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class PinValidator @Inject constructor() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@VisibleForTesting
|
||||||
|
val BLACKLIST = listOf("0000", "1234")
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Result {
|
||||||
|
data object Valid : Result
|
||||||
|
data class Invalid(val failure: CreatePinFailure) : Result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPinValid(pinEntry: PinEntry): Result {
|
||||||
|
val pinAsText = pinEntry.toText()
|
||||||
|
val isBlacklisted = BLACKLIST.any { it == pinAsText }
|
||||||
|
return if (isBlacklisted) {
|
||||||
|
Result.Invalid(CreatePinFailure.PinBlacklisted)
|
||||||
|
} else {
|
||||||
|
Result.Valid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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,114 @@
|
||||||
|
/*
|
||||||
|
* 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 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.create.model.PinDigit
|
||||||
|
import io.element.android.features.lockscreen.impl.create.model.PinEntry
|
||||||
|
import io.element.android.features.lockscreen.impl.create.validation.CreatePinFailure
|
||||||
|
import io.element.android.features.lockscreen.impl.create.validation.PinValidator
|
||||||
|
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 CreatePinPresenterTest {
|
||||||
|
|
||||||
|
private val blacklistedPin = PinValidator.BLACKLIST.first()
|
||||||
|
private val halfCompletePin = "12"
|
||||||
|
private val completePin = "1235"
|
||||||
|
private val mismatchedPin = "1236"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - complete flow`() = runTest {
|
||||||
|
|
||||||
|
val presenter = createCreatePinPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
awaitItem().also { state ->
|
||||||
|
state.choosePinEntry.assertEmpty()
|
||||||
|
state.confirmPinEntry.assertEmpty()
|
||||||
|
assertThat(state.createPinFailure).isNull()
|
||||||
|
assertThat(state.isConfirmationStep).isFalse()
|
||||||
|
state.eventSink(CreatePinEvents.OnPinEntryChanged(halfCompletePin))
|
||||||
|
}
|
||||||
|
awaitItem().also { state ->
|
||||||
|
state.choosePinEntry.assertText(halfCompletePin)
|
||||||
|
state.confirmPinEntry.assertEmpty()
|
||||||
|
assertThat(state.createPinFailure).isNull()
|
||||||
|
assertThat(state.isConfirmationStep).isFalse()
|
||||||
|
state.eventSink(CreatePinEvents.OnPinEntryChanged(blacklistedPin))
|
||||||
|
}
|
||||||
|
awaitLastSequentialItem().also { state ->
|
||||||
|
state.choosePinEntry.assertText(blacklistedPin)
|
||||||
|
assertThat(state.createPinFailure).isEqualTo(CreatePinFailure.PinBlacklisted)
|
||||||
|
state.eventSink(CreatePinEvents.ClearFailure)
|
||||||
|
}
|
||||||
|
awaitLastSequentialItem().also { state ->
|
||||||
|
state.choosePinEntry.assertEmpty()
|
||||||
|
assertThat(state.createPinFailure).isNull()
|
||||||
|
state.eventSink(CreatePinEvents.OnPinEntryChanged(completePin))
|
||||||
|
}
|
||||||
|
awaitLastSequentialItem().also { state ->
|
||||||
|
state.choosePinEntry.assertText(completePin)
|
||||||
|
state.confirmPinEntry.assertEmpty()
|
||||||
|
assertThat(state.isConfirmationStep).isTrue()
|
||||||
|
state.eventSink(CreatePinEvents.OnPinEntryChanged(mismatchedPin))
|
||||||
|
}
|
||||||
|
awaitLastSequentialItem().also { state ->
|
||||||
|
state.choosePinEntry.assertText(completePin)
|
||||||
|
state.confirmPinEntry.assertText(mismatchedPin)
|
||||||
|
assertThat(state.createPinFailure).isEqualTo(CreatePinFailure.PinsDontMatch)
|
||||||
|
state.eventSink(CreatePinEvents.ClearFailure)
|
||||||
|
}
|
||||||
|
awaitLastSequentialItem().also { state ->
|
||||||
|
state.choosePinEntry.assertEmpty()
|
||||||
|
state.confirmPinEntry.assertEmpty()
|
||||||
|
assertThat(state.isConfirmationStep).isFalse()
|
||||||
|
assertThat(state.createPinFailure).isNull()
|
||||||
|
state.eventSink(CreatePinEvents.OnPinEntryChanged(completePin))
|
||||||
|
}
|
||||||
|
awaitLastSequentialItem().also { state ->
|
||||||
|
state.choosePinEntry.assertText(completePin)
|
||||||
|
state.confirmPinEntry.assertEmpty()
|
||||||
|
assertThat(state.isConfirmationStep).isTrue()
|
||||||
|
state.eventSink(CreatePinEvents.OnPinEntryChanged(completePin))
|
||||||
|
}
|
||||||
|
awaitItem().also { state ->
|
||||||
|
state.choosePinEntry.assertText(completePin)
|
||||||
|
state.confirmPinEntry.assertText(completePin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PinEntry.assertText(text: String) {
|
||||||
|
assertThat(toText()).isEqualTo(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PinEntry.assertEmpty() {
|
||||||
|
val isEmpty = digits.all { it is PinDigit.Empty }
|
||||||
|
assertThat(isEmpty).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCreatePinPresenter(): CreatePinPresenter {
|
||||||
|
return CreatePinPresenter(PinValidator(), aBuildMeta())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,7 @@ anvil {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(projects.anvilannotations)
|
implementation(projects.anvilannotations)
|
||||||
|
implementation(projects.appconfig)
|
||||||
anvil(projects.anvilcodegen)
|
anvil(projects.anvilcodegen)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
package io.element.android.features.login.impl.accountprovider
|
package io.element.android.features.login.impl.accountprovider
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
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> {
|
open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> {
|
||||||
override val values: Sequence<AccountProvider>
|
override val values: Sequence<AccountProvider>
|
||||||
|
|
@ -32,7 +32,7 @@ open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun anAccountProvider() = 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.",
|
subtitle = "Matrix.org is an open network for secure, decentralized communication.",
|
||||||
isPublic = true,
|
isPublic = true,
|
||||||
isMatrixOrg = true,
|
isMatrixOrg = true,
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
package io.element.android.features.login.impl.screens.changeaccountprovider
|
package io.element.android.features.login.impl.screens.changeaccountprovider
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
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.accountprovider.AccountProvider
|
||||||
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
|
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 io.element.android.libraries.architecture.Presenter
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -34,7 +34,7 @@ class ChangeAccountProviderPresenter @Inject constructor(
|
||||||
// Just matrix.org by default for now
|
// Just matrix.org by default for now
|
||||||
accountProviders = listOf(
|
accountProviders = listOf(
|
||||||
AccountProvider(
|
AccountProvider(
|
||||||
url = LoginConstants.MATRIX_ORG_URL,
|
url = AuthenticationConfig.MATRIX_ORG_URL,
|
||||||
subtitle = null,
|
subtitle = null,
|
||||||
isPublic = true,
|
isPublic = true,
|
||||||
isMatrixOrg = true,
|
isMatrixOrg = true,
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
package io.element.android.features.login.impl.screens.searchaccountprovider
|
package io.element.android.features.login.impl.screens.searchaccountprovider
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
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.changeserver.aChangeServerState
|
||||||
import io.element.android.features.login.impl.resolver.HomeserverData
|
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.architecture.Async
|
||||||
|
|
||||||
open class SearchAccountProviderStateProvider : PreviewParameterProvider<SearchAccountProviderState> {
|
open class SearchAccountProviderStateProvider : PreviewParameterProvider<SearchAccountProviderState> {
|
||||||
|
|
@ -50,7 +50,7 @@ fun aHomeserverDataList(): List<HomeserverData> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aHomeserverData(
|
fun aHomeserverData(
|
||||||
homeserverUrl: String = LoginConstants.MATRIX_ORG_URL,
|
homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL,
|
||||||
isWellknownValid: Boolean = true,
|
isWellknownValid: Boolean = true,
|
||||||
supportSlidingSync: Boolean = true,
|
supportSlidingSync: Boolean = true,
|
||||||
): HomeserverData {
|
): HomeserverData {
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,11 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.searchaccountprovider
|
package io.element.android.features.login.impl.screens.searchaccountprovider
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.text.input.KeyboardType
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
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.R
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
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.accountprovider.AccountProviderView
|
||||||
import io.element.android.features.login.impl.changeserver.ChangeServerEvents
|
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.changeserver.ChangeServerView
|
||||||
import io.element.android.features.login.impl.resolver.HomeserverData
|
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.architecture.Async
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
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.button.BackButton
|
||||||
|
|
@ -196,7 +195,7 @@ fun SearchAccountProviderView(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HomeserverData.toAccountProvider(): AccountProvider {
|
private fun HomeserverData.toAccountProvider(): AccountProvider {
|
||||||
val isMatrixOrg = homeserverUrl == LoginConstants.MATRIX_ORG_URL
|
val isMatrixOrg = homeserverUrl == AuthenticationConfig.MATRIX_ORG_URL
|
||||||
return AccountProvider(
|
return AccountProvider(
|
||||||
url = homeserverUrl,
|
url = homeserverUrl,
|
||||||
subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null,
|
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
|
package io.element.android.features.login.impl.util
|
||||||
|
|
||||||
|
import io.element.android.appconfig.AuthenticationConfig
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
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(
|
val defaultAccountProvider = AccountProvider(
|
||||||
url = LoginConstants.DEFAULT_HOMESERVER_URL,
|
url = AuthenticationConfig.DEFAULT_HOMESERVER_URL,
|
||||||
subtitle = null,
|
subtitle = null,
|
||||||
isPublic = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL,
|
isPublic = AuthenticationConfig.DEFAULT_HOMESERVER_URL == AuthenticationConfig.MATRIX_ORG_URL,
|
||||||
isMatrixOrg = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import io.element.android.appconfig.AuthenticationConfig
|
||||||
import io.element.android.libraries.core.data.tryOrNull
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
|
|
||||||
fun openLearnMorePage(context: Context) {
|
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) }
|
tryOrNull { context.startActivity(intent) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<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_content">"Are you sure you want to sign out?"</string>
|
||||||
<string name="screen_signout_confirmation_dialog_title">"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_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">"Have you saved your recovery key?"</string>
|
||||||
<string name="screen_signout_confirmation_dialog_submit">"Sign out"</string>
|
<string name="screen_signout_confirmation_dialog_submit">"Sign out"</string>
|
||||||
<string name="screen_signout_preference_item">"Sign out"</string>
|
<string name="screen_signout_preference_item">"Sign out"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ dependencies {
|
||||||
implementation(projects.anvilannotations)
|
implementation(projects.anvilannotations)
|
||||||
anvil(projects.anvilcodegen)
|
anvil(projects.anvilcodegen)
|
||||||
api(projects.features.messages.api)
|
api(projects.features.messages.api)
|
||||||
|
implementation(projects.features.call)
|
||||||
implementation(projects.features.location.api)
|
implementation(projects.features.location.api)
|
||||||
implementation(projects.features.poll.api)
|
implementation(projects.features.poll.api)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl
|
package io.element.android.features.messages.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -29,6 +30,8 @@ import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
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.Location
|
||||||
import io.element.android.features.location.api.SendLocationEntryPoint
|
import io.element.android.features.location.api.SendLocationEntryPoint
|
||||||
import io.element.android.features.location.api.ShowLocationEntryPoint
|
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.BackstackNode
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
import io.element.android.libraries.architecture.createNode
|
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.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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.core.UserId
|
||||||
|
|
@ -63,6 +68,8 @@ import kotlinx.parcelize.Parcelize
|
||||||
class MessagesFlowNode @AssistedInject constructor(
|
class MessagesFlowNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val matrixClient: MatrixClient,
|
||||||
private val sendLocationEntryPoint: SendLocationEntryPoint,
|
private val sendLocationEntryPoint: SendLocationEntryPoint,
|
||||||
private val showLocationEntryPoint: ShowLocationEntryPoint,
|
private val showLocationEntryPoint: ShowLocationEntryPoint,
|
||||||
private val createPollEntryPoint: CreatePollEntryPoint,
|
private val createPollEntryPoint: CreatePollEntryPoint,
|
||||||
|
|
@ -149,6 +156,14 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
override fun onCreatePollClicked() {
|
override fun onCreatePollClicked() {
|
||||||
backstack.push(NavTarget.CreatePoll)
|
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))
|
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.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
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 onReportMessage(eventId: EventId, senderId: UserId)
|
||||||
fun onSendLocationClicked()
|
fun onSendLocationClicked()
|
||||||
fun onCreatePollClicked()
|
fun onCreatePollClicked()
|
||||||
|
fun onJoinCallClicked(roomId: RoomId)
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|
@ -108,6 +110,10 @@ class MessagesNode @AssistedInject constructor(
|
||||||
callback?.onCreatePollClicked()
|
callback?.onCreatePollClicked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onJoinCallClicked() {
|
||||||
|
callback?.onJoinCallClicked(room.roomId)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
|
|
@ -123,6 +129,7 @@ class MessagesNode @AssistedInject constructor(
|
||||||
onUserDataClicked = this::onUserDataClicked,
|
onUserDataClicked = this::onUserDataClicked,
|
||||||
onSendLocationClicked = this::onSendLocationClicked,
|
onSendLocationClicked = this::onSendLocationClicked,
|
||||||
onCreatePollClicked = this::onCreatePollClicked,
|
onCreatePollClicked = this::onCreatePollClicked,
|
||||||
|
onJoinCallClicked = this::onJoinCallClicked,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,10 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
|
val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
|
||||||
|
|
||||||
var enableVoiceMessages by remember { mutableStateOf(false) }
|
var enableVoiceMessages by remember { mutableStateOf(false) }
|
||||||
|
var enableInRoomCalls by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(featureFlagsService) {
|
LaunchedEffect(featureFlagsService) {
|
||||||
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
|
enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
|
||||||
|
enableInRoomCalls = featureFlagsService.isFeatureEnabled(FeatureFlags.InRoomCalls)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleEvents(event: MessagesEvents) {
|
fun handleEvents(event: MessagesEvents) {
|
||||||
|
|
@ -200,6 +202,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
inviteProgress = inviteProgress.value,
|
inviteProgress = inviteProgress.value,
|
||||||
enableTextFormatting = enableTextFormatting,
|
enableTextFormatting = enableTextFormatting,
|
||||||
enableVoiceMessages = enableVoiceMessages,
|
enableVoiceMessages = enableVoiceMessages,
|
||||||
|
enableInRoomCalls = enableInRoomCalls,
|
||||||
eventSink = { handleEvents(it) }
|
eventSink = { handleEvents(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,5 +49,6 @@ data class MessagesState(
|
||||||
val showReinvitePrompt: Boolean,
|
val showReinvitePrompt: Boolean,
|
||||||
val enableTextFormatting: Boolean,
|
val enableTextFormatting: Boolean,
|
||||||
val enableVoiceMessages: Boolean,
|
val enableVoiceMessages: Boolean,
|
||||||
|
val enableInRoomCalls: Boolean,
|
||||||
val eventSink: (MessagesEvents) -> Unit
|
val eventSink: (MessagesEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -85,5 +85,6 @@ fun aMessagesState() = MessagesState(
|
||||||
showReinvitePrompt = false,
|
showReinvitePrompt = false,
|
||||||
enableTextFormatting = true,
|
enableTextFormatting = true,
|
||||||
enableVoiceMessages = true,
|
enableVoiceMessages = true,
|
||||||
|
enableInRoomCalls = true,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,12 @@ import io.element.android.libraries.designsystem.components.dialogs.Confirmation
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.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.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
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.LogCompositions
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||||
|
|
@ -99,6 +102,7 @@ fun MessagesView(
|
||||||
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
||||||
onSendLocationClicked: () -> Unit,
|
onSendLocationClicked: () -> Unit,
|
||||||
onCreatePollClicked: () -> Unit,
|
onCreatePollClicked: () -> Unit,
|
||||||
|
onJoinCallClicked: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
LogCompositions(tag = "MessagesScreen", msg = "Root")
|
||||||
|
|
@ -160,8 +164,10 @@ fun MessagesView(
|
||||||
MessagesViewTopBar(
|
MessagesViewTopBar(
|
||||||
roomName = state.roomName.dataOrNull(),
|
roomName = state.roomName.dataOrNull(),
|
||||||
roomAvatar = state.roomAvatar.dataOrNull(),
|
roomAvatar = state.roomAvatar.dataOrNull(),
|
||||||
|
inRoomCallsEnabled = state.enableInRoomCalls,
|
||||||
onBackPressed = onBackPressed,
|
onBackPressed = onBackPressed,
|
||||||
onRoomDetailsClicked = onRoomDetailsClicked,
|
onRoomDetailsClicked = onRoomDetailsClicked,
|
||||||
|
onJoinCallClicked = onJoinCallClicked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -349,8 +355,10 @@ private fun MessagesViewContent(
|
||||||
private fun MessagesViewTopBar(
|
private fun MessagesViewTopBar(
|
||||||
roomName: String?,
|
roomName: String?,
|
||||||
roomAvatar: AvatarData?,
|
roomAvatar: AvatarData?,
|
||||||
|
inRoomCallsEnabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onRoomDetailsClicked: () -> Unit = {},
|
onRoomDetailsClicked: () -> Unit = {},
|
||||||
|
onJoinCallClicked: () -> Unit = {},
|
||||||
onBackPressed: () -> Unit = {},
|
onBackPressed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|
@ -373,6 +381,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)
|
windowInsets = WindowInsets(0.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -432,5 +447,6 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
|
||||||
onUserDataClicked = {},
|
onUserDataClicked = {},
|
||||||
onSendLocationClicked = {},
|
onSendLocationClicked = {},
|
||||||
onCreatePollClicked = {},
|
onCreatePollClicked = {},
|
||||||
|
onJoinCallClicked = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ dependencies {
|
||||||
implementation(projects.anvilannotations)
|
implementation(projects.anvilannotations)
|
||||||
anvil(projects.anvilcodegen)
|
anvil(projects.anvilcodegen)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
|
implementation(projects.appconfig)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
|
|
||||||
|
|
@ -19,4 +19,5 @@ package io.element.android.features.preferences.impl.advanced
|
||||||
sealed interface AdvancedSettingsEvents {
|
sealed interface AdvancedSettingsEvents {
|
||||||
data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
||||||
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
|
||||||
|
data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AdvancedSettingsEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,25 @@
|
||||||
package io.element.android.features.preferences.impl.advanced
|
package io.element.android.features.preferences.impl.advanced
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import io.element.android.appconfig.ElementCallConfig
|
||||||
import io.element.android.features.preferences.api.store.PreferencesStore
|
import io.element.android.features.preferences.api.store.PreferencesStore
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.net.URL
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class AdvancedSettingsPresenter @Inject constructor(
|
class AdvancedSettingsPresenter @Inject constructor(
|
||||||
private val preferencesStore: PreferencesStore,
|
private val preferencesStore: PreferencesStore,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
) : Presenter<AdvancedSettingsState> {
|
) : Presenter<AdvancedSettingsState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -38,6 +47,14 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||||
val isDeveloperModeEnabled by preferencesStore
|
val isDeveloperModeEnabled by preferencesStore
|
||||||
.isDeveloperModeEnabledFlow()
|
.isDeveloperModeEnabledFlow()
|
||||||
.collectAsState(initial = false)
|
.collectAsState(initial = false)
|
||||||
|
val customElementCallBaseUrl by preferencesStore
|
||||||
|
.getCustomElementCallBaseUrlFlow()
|
||||||
|
.collectAsState(initial = null)
|
||||||
|
|
||||||
|
var canDisplayElementCallSettings by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
canDisplayElementCallSettings = featureFlagService.isFeatureEnabled(FeatureFlags.InRoomCalls)
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvents(event: AdvancedSettingsEvents) {
|
fun handleEvents(event: AdvancedSettingsEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
|
|
@ -47,13 +64,34 @@ class AdvancedSettingsPresenter @Inject constructor(
|
||||||
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch {
|
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch {
|
||||||
preferencesStore.setDeveloperModeEnabled(event.enabled)
|
preferencesStore.setDeveloperModeEnabled(event.enabled)
|
||||||
}
|
}
|
||||||
|
is AdvancedSettingsEvents.SetCustomElementCallBaseUrl -> localCoroutineScope.launch {
|
||||||
|
// If the URL is either empty or the default one, we want to save 'null' to remove the custom URL
|
||||||
|
val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() && it != ElementCallConfig.DEFAULT_BASE_URL }
|
||||||
|
preferencesStore.setCustomElementCallBaseUrl(urlToSave)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return AdvancedSettingsState(
|
return AdvancedSettingsState(
|
||||||
isRichTextEditorEnabled = isRichTextEditorEnabled,
|
isRichTextEditorEnabled = isRichTextEditorEnabled,
|
||||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||||
|
customElementCallBaseUrlState = if (canDisplayElementCallSettings) {
|
||||||
|
CustomElementCallBaseUrlState(
|
||||||
|
baseUrl = customElementCallBaseUrl,
|
||||||
|
defaultUrl = ElementCallConfig.DEFAULT_BASE_URL,
|
||||||
|
validator = ::customElementCallUrlValidator,
|
||||||
|
)
|
||||||
|
} else null,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun customElementCallUrlValidator(url: String?): Boolean {
|
||||||
|
return runCatching {
|
||||||
|
if (url.isNullOrEmpty()) return@runCatching
|
||||||
|
val parsedUrl = URL(url)
|
||||||
|
if (parsedUrl.protocol !in listOf("http", "https")) error("Incorrect protocol")
|
||||||
|
if (parsedUrl.host.isNullOrBlank()) error("Missing host")
|
||||||
|
}.isSuccess
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,15 @@
|
||||||
|
|
||||||
package io.element.android.features.preferences.impl.advanced
|
package io.element.android.features.preferences.impl.advanced
|
||||||
|
|
||||||
data class AdvancedSettingsState constructor(
|
data class AdvancedSettingsState(
|
||||||
val isRichTextEditorEnabled: Boolean,
|
val isRichTextEditorEnabled: Boolean,
|
||||||
val isDeveloperModeEnabled: Boolean,
|
val isDeveloperModeEnabled: Boolean,
|
||||||
|
val customElementCallBaseUrlState: CustomElementCallBaseUrlState?,
|
||||||
val eventSink: (AdvancedSettingsEvents) -> Unit
|
val eventSink: (AdvancedSettingsEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class CustomElementCallBaseUrlState(
|
||||||
|
val baseUrl: String?,
|
||||||
|
val defaultUrl: String,
|
||||||
|
val validator: (String?) -> Boolean,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,17 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
|
||||||
aAdvancedSettingsState(),
|
aAdvancedSettingsState(),
|
||||||
aAdvancedSettingsState(isRichTextEditorEnabled = true),
|
aAdvancedSettingsState(isRichTextEditorEnabled = true),
|
||||||
aAdvancedSettingsState(isDeveloperModeEnabled = true),
|
aAdvancedSettingsState(isDeveloperModeEnabled = true),
|
||||||
|
aAdvancedSettingsState(customElementCallBaseUrl = "https://call.element.io"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aAdvancedSettingsState(
|
fun aAdvancedSettingsState(
|
||||||
isRichTextEditorEnabled: Boolean = false,
|
isRichTextEditorEnabled: Boolean = false,
|
||||||
isDeveloperModeEnabled: Boolean = false,
|
isDeveloperModeEnabled: Boolean = false,
|
||||||
|
customElementCallBaseUrl: String? = null,
|
||||||
) = AdvancedSettingsState(
|
) = AdvancedSettingsState(
|
||||||
isRichTextEditorEnabled = isRichTextEditorEnabled,
|
isRichTextEditorEnabled = isRichTextEditorEnabled,
|
||||||
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
isDeveloperModeEnabled = isDeveloperModeEnabled,
|
||||||
|
customElementCallBaseUrlState = customElementCallBaseUrl?.let { CustomElementCallBaseUrlState(it, "https://call.element.io") { true } },
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,16 @@
|
||||||
|
|
||||||
package io.element.android.features.preferences.impl.advanced
|
package io.element.android.features.preferences.impl.advanced
|
||||||
|
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import io.element.android.features.preferences.impl.R
|
import io.element.android.features.preferences.impl.R
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
|
||||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||||
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
||||||
|
import io.element.android.libraries.designsystem.components.preferences.PreferenceTextField
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
@ -33,6 +36,11 @@ fun AdvancedSettingsView(
|
||||||
onBackPressed: () -> Unit,
|
onBackPressed: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
fun isUsingDefaultUrl(value: String?): Boolean {
|
||||||
|
val defaultUrl = state.customElementCallBaseUrlState?.defaultUrl ?: return false
|
||||||
|
return value.isNullOrEmpty() || value == defaultUrl
|
||||||
|
}
|
||||||
|
|
||||||
PreferencePage(
|
PreferencePage(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onBackPressed = onBackPressed,
|
onBackPressed = onBackPressed,
|
||||||
|
|
@ -50,6 +58,23 @@ fun AdvancedSettingsView(
|
||||||
isChecked = state.isDeveloperModeEnabled,
|
isChecked = state.isDeveloperModeEnabled,
|
||||||
onCheckedChange = { state.eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(it)) },
|
onCheckedChange = { state.eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(it)) },
|
||||||
)
|
)
|
||||||
|
state.customElementCallBaseUrlState?.let { callUrlState ->
|
||||||
|
val supportingText = if (isUsingDefaultUrl(callUrlState.baseUrl)) {
|
||||||
|
stringResource(R.string.screen_advanced_settings_element_call_base_url_description)
|
||||||
|
} else {
|
||||||
|
callUrlState.baseUrl
|
||||||
|
}
|
||||||
|
PreferenceTextField(
|
||||||
|
headline = stringResource(R.string.screen_advanced_settings_element_call_base_url),
|
||||||
|
value = callUrlState.baseUrl ?: callUrlState.defaultUrl,
|
||||||
|
supportingText = supportingText,
|
||||||
|
validation = callUrlState.validator,
|
||||||
|
onValidationErrorMessage = stringResource(R.string.screen_advanced_settings_element_call_base_url_validation_error),
|
||||||
|
displayValue = { value -> !isUsingDefaultUrl(value) },
|
||||||
|
keyboardOptions = KeyboardOptions.Default.copy(autoCorrect = false, keyboardType = KeyboardType.Uri),
|
||||||
|
onChange = { state.eventSink(AdvancedSettingsEvents.SetCustomElementCallBaseUrl(it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="screen_advanced_settings_element_call_base_url">"Custom Element Call base URL"</string>
|
||||||
|
<string name="screen_advanced_settings_element_call_base_url_description">"Set a custom base URL for Element Call."</string>
|
||||||
|
<string name="screen_advanced_settings_element_call_base_url_validation_error">"Invalid URL, please make sure you include the protocol (http/https) and the correct address."</string>
|
||||||
<string name="screen_advanced_settings_developer_mode">"Developer mode"</string>
|
<string name="screen_advanced_settings_developer_mode">"Developer mode"</string>
|
||||||
<string name="screen_advanced_settings_developer_mode_description">"Enable to have access to features and functionality for developers."</string>
|
<string name="screen_advanced_settings_developer_mode_description">"Enable to have access to features and functionality for developers."</string>
|
||||||
<string name="screen_advanced_settings_rich_text_editor_description">"Disable the rich text editor to type Markdown manually."</string>
|
<string name="screen_advanced_settings_rich_text_editor_description">"Disable the rich text editor to type Markdown manually."</string>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import app.cash.molecule.RecompositionMode
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore
|
import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -34,7 +36,8 @@ class AdvancedSettingsPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
val store = InMemoryPreferencesStore()
|
val store = InMemoryPreferencesStore()
|
||||||
val presenter = AdvancedSettingsPresenter(store)
|
val featureFlagService = FakeFeatureFlagService()
|
||||||
|
val presenter = AdvancedSettingsPresenter(store, featureFlagService)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -47,7 +50,8 @@ class AdvancedSettingsPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - developer mode on off`() = runTest {
|
fun `present - developer mode on off`() = runTest {
|
||||||
val store = InMemoryPreferencesStore()
|
val store = InMemoryPreferencesStore()
|
||||||
val presenter = AdvancedSettingsPresenter(store)
|
val featureFlagService = FakeFeatureFlagService()
|
||||||
|
val presenter = AdvancedSettingsPresenter(store, featureFlagService)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -63,7 +67,8 @@ class AdvancedSettingsPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - rich text editor on off`() = runTest {
|
fun `present - rich text editor on off`() = runTest {
|
||||||
val store = InMemoryPreferencesStore()
|
val store = InMemoryPreferencesStore()
|
||||||
val presenter = AdvancedSettingsPresenter(store)
|
val featureFlagService = FakeFeatureFlagService()
|
||||||
|
val presenter = AdvancedSettingsPresenter(store, featureFlagService)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -75,4 +80,64 @@ class AdvancedSettingsPresenterTest {
|
||||||
assertThat(awaitItem().isRichTextEditorEnabled).isFalse()
|
assertThat(awaitItem().isRichTextEditorEnabled).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - custom element call url state is null if the feature flag is disabled`() = runTest {
|
||||||
|
val store = InMemoryPreferencesStore()
|
||||||
|
val featureFlagService = FakeFeatureFlagService().apply {
|
||||||
|
setFeatureEnabled(FeatureFlags.InRoomCalls, false)
|
||||||
|
}
|
||||||
|
val presenter = AdvancedSettingsPresenter(store, featureFlagService)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.customElementCallBaseUrlState).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - custom element call base url`() = runTest {
|
||||||
|
val store = InMemoryPreferencesStore()
|
||||||
|
val featureFlagService = FakeFeatureFlagService().apply {
|
||||||
|
setFeatureEnabled(FeatureFlags.InRoomCalls, true)
|
||||||
|
}
|
||||||
|
val presenter = AdvancedSettingsPresenter(store, featureFlagService)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
// Initial state has a default `false` feature flag value, so the state will still be null
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.customElementCallBaseUrlState).isNotNull()
|
||||||
|
assertThat(initialState.customElementCallBaseUrlState?.baseUrl).isNull()
|
||||||
|
|
||||||
|
initialState.eventSink(AdvancedSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.dev"))
|
||||||
|
val updatedItem = awaitItem()
|
||||||
|
assertThat(updatedItem.customElementCallBaseUrlState?.baseUrl).isEqualTo("https://call.element.dev")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - custom element call base url validator needs at least an HTTP scheme and host`() = runTest {
|
||||||
|
val store = InMemoryPreferencesStore()
|
||||||
|
val featureFlagService = FakeFeatureFlagService().apply {
|
||||||
|
setFeatureEnabled(FeatureFlags.InRoomCalls, true)
|
||||||
|
}
|
||||||
|
val presenter = AdvancedSettingsPresenter(store, featureFlagService)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
// Initial state has a default `false` feature flag value, so the state will still be null
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val urlValidator = awaitItem().customElementCallBaseUrlState!!.validator
|
||||||
|
assertThat(urlValidator("")).isTrue() // We allow empty string to clear the value and use the default one
|
||||||
|
assertThat(urlValidator("test")).isFalse()
|
||||||
|
assertThat(urlValidator("http://")).isFalse()
|
||||||
|
assertThat(urlValidator("geo://test")).isFalse()
|
||||||
|
assertThat(urlValidator("https://call.element.io")).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ coil = "2.4.0"
|
||||||
datetime = "0.4.1"
|
datetime = "0.4.1"
|
||||||
serialization_json = "1.6.0"
|
serialization_json = "1.6.0"
|
||||||
showkase = "1.0.0-beta18"
|
showkase = "1.0.0-beta18"
|
||||||
jsoup = "1.16.1"
|
jsoup = "1.16.2"
|
||||||
appyx = "1.4.0"
|
appyx = "1.4.0"
|
||||||
dependencycheck = "8.4.0"
|
dependencycheck = "8.4.0"
|
||||||
dependencyanalysis = "1.25.0"
|
dependencyanalysis = "1.25.0"
|
||||||
|
|
@ -70,7 +70,7 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref
|
||||||
android_desugar = "com.android.tools:desugar_jdk_libs:2.0.3"
|
android_desugar = "com.android.tools:desugar_jdk_libs:2.0.3"
|
||||||
kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||||
# https://firebase.google.com/docs/android/setup#available-libraries
|
# https://firebase.google.com/docs/android/setup#available-libraries
|
||||||
google_firebase_bom = "com.google.firebase:firebase-bom:32.3.1"
|
google_firebase_bom = "com.google.firebase:firebase-bom:32.4.0"
|
||||||
|
|
||||||
# AndroidX
|
# AndroidX
|
||||||
androidx_core = { module = "androidx.core:core", version.ref = "core" }
|
androidx_core = { module = "androidx.core:core", version.ref = "core" }
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import android.content.ContextWrapper
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import io.element.android.libraries.di.DaggerComponentOwner
|
import io.element.android.libraries.di.DaggerComponentOwner
|
||||||
|
|
||||||
|
inline fun <reified T : Any> Node.optionalBindings() = optionalBindings(T::class.java)
|
||||||
inline fun <reified T : Any> Node.bindings() = bindings(T::class.java)
|
inline fun <reified T : Any> Node.bindings() = bindings(T::class.java)
|
||||||
inline fun <reified T : Any> Context.bindings() = bindings(T::class.java)
|
inline fun <reified T : Any> Context.bindings() = bindings(T::class.java)
|
||||||
|
|
||||||
|
|
@ -36,7 +37,7 @@ fun <T : Any> Context.bindings(klass: Class<T>): T {
|
||||||
?: error("Unable to find bindings for ${klass.name}")
|
?: error("Unable to find bindings for ${klass.name}")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T : Any> Node.bindings(klass: Class<T>): T {
|
fun <T : Any> Node.optionalBindings(klass: Class<T>): T? {
|
||||||
// search dagger components in node hierarchy
|
// search dagger components in node hierarchy
|
||||||
return generateSequence(this, Node::parent)
|
return generateSequence(this, Node::parent)
|
||||||
.filterIsInstance<DaggerComponentOwner>()
|
.filterIsInstance<DaggerComponentOwner>()
|
||||||
|
|
@ -44,5 +45,8 @@ fun <T : Any> Node.bindings(klass: Class<T>): T {
|
||||||
.flatMap { if (it is Collection<*>) it else listOf(it) }
|
.flatMap { if (it is Collection<*>) it else listOf(it) }
|
||||||
.filterIsInstance(klass)
|
.filterIsInstance(klass)
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
?: error("Unable to find bindings for ${klass.name}")
|
}
|
||||||
|
|
||||||
|
fun <T : Any> Node.bindings(klass: Class<T>): T {
|
||||||
|
return optionalBindings(klass) ?: error("Unable to find bindings for ${klass.name}")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,9 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.airbnb.android.showkase.annotation.ShowkaseComposable
|
import com.airbnb.android.showkase.annotation.ShowkaseComposable
|
||||||
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
|
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.DialogPreview
|
import io.element.android.libraries.designsystem.theme.components.DialogPreview
|
||||||
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
|
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
|
||||||
import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent
|
import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent
|
||||||
|
|
@ -45,6 +45,7 @@ fun ListDialog(
|
||||||
subtitle: String? = null,
|
subtitle: String? = null,
|
||||||
cancelText: String = stringResource(CommonStrings.action_cancel),
|
cancelText: String = stringResource(CommonStrings.action_cancel),
|
||||||
submitText: String = stringResource(CommonStrings.action_ok),
|
submitText: String = stringResource(CommonStrings.action_ok),
|
||||||
|
enabled: Boolean = true,
|
||||||
listItems: LazyListScope.() -> Unit,
|
listItems: LazyListScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
|
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
|
||||||
|
|
@ -66,6 +67,7 @@ fun ListDialog(
|
||||||
submitText = submitText,
|
submitText = submitText,
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
onSubmitClicked = onSubmit,
|
onSubmitClicked = onSubmit,
|
||||||
|
enabled = enabled,
|
||||||
listItems = listItems,
|
listItems = listItems,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -80,6 +82,7 @@ private fun ListDialogContent(
|
||||||
submitText: String,
|
submitText: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
title: String? = null,
|
title: String? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
subtitle: @Composable (() -> Unit)? = null,
|
subtitle: @Composable (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
SimpleAlertDialogContent(
|
SimpleAlertDialogContent(
|
||||||
|
|
@ -90,6 +93,7 @@ private fun ListDialogContent(
|
||||||
submitText = submitText,
|
submitText = submitText,
|
||||||
onCancelClicked = onDismissRequest,
|
onCancelClicked = onDismissRequest,
|
||||||
onSubmitClicked = onSubmitClicked,
|
onSubmitClicked = onSubmitClicked,
|
||||||
|
enabled = enabled,
|
||||||
applyPaddingToContents = false,
|
applyPaddingToContents = false,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,13 @@
|
||||||
|
|
||||||
package io.element.android.libraries.designsystem.components.list
|
package io.element.android.libraries.designsystem.components.list
|
||||||
|
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||||
|
|
@ -29,24 +32,68 @@ import io.element.android.libraries.theme.ElementTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TextFieldListItem(
|
fun TextFieldListItem(
|
||||||
placeholder: String,
|
placeholder: String?,
|
||||||
text: String,
|
text: String,
|
||||||
onTextChanged: (String) -> Unit,
|
onTextChanged: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
error: String? = null,
|
||||||
|
maxLines: Int = 1,
|
||||||
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
) {
|
) {
|
||||||
val textFieldStyle = ElementTheme.materialTypography.bodyLarge
|
val textFieldStyle = ElementTheme.materialTypography.bodyLarge
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = onTextChanged,
|
onValueChange = { onTextChanged(it) },
|
||||||
placeholder = { Text(placeholder) },
|
placeholder = placeholder?.let { @Composable { Text(it) } },
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
disabledBorderColor = Color.Transparent,
|
disabledBorderColor = Color.Transparent,
|
||||||
errorBorderColor = Color.Transparent,
|
errorBorderColor = Color.Transparent,
|
||||||
focusedBorderColor = Color.Transparent,
|
focusedBorderColor = Color.Transparent,
|
||||||
unfocusedBorderColor = Color.Transparent,
|
unfocusedBorderColor = Color.Transparent,
|
||||||
),
|
),
|
||||||
|
isError = error != null,
|
||||||
|
supportingText = error?.let { @Composable { Text(it) } },
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
keyboardActions = keyboardActions,
|
||||||
textStyle = textFieldStyle,
|
textStyle = textFieldStyle,
|
||||||
|
maxLines = maxLines,
|
||||||
|
singleLine = maxLines == 1,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TextFieldListItem(
|
||||||
|
placeholder: String?,
|
||||||
|
text: TextFieldValue,
|
||||||
|
onTextChanged: (TextFieldValue) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
error: String? = null,
|
||||||
|
maxLines: Int = 1,
|
||||||
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
|
) {
|
||||||
|
val textFieldStyle = ElementTheme.materialTypography.bodyLarge
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { onTextChanged(it) },
|
||||||
|
placeholder = placeholder?.let { @Composable { Text(it) } },
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
disabledBorderColor = Color.Transparent,
|
||||||
|
errorBorderColor = Color.Transparent,
|
||||||
|
focusedBorderColor = Color.Transparent,
|
||||||
|
unfocusedBorderColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
isError = error != null,
|
||||||
|
supportingText = error?.let { @Composable { Text(it) } },
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
keyboardActions = keyboardActions,
|
||||||
|
textStyle = textFieldStyle,
|
||||||
|
maxLines = maxLines,
|
||||||
|
singleLine = maxLines == 1,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -74,3 +121,15 @@ internal fun TextFieldListItemPreview() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview("Text field List item - textfieldvalue", group = PreviewGroup.ListItems)
|
||||||
|
@Composable
|
||||||
|
internal fun TextFieldListItemTextFieldValuePreview() {
|
||||||
|
ElementThemedPreview {
|
||||||
|
TextFieldListItem(
|
||||||
|
placeholder = "Placeholder",
|
||||||
|
text = TextFieldValue("Text field value"),
|
||||||
|
onTextChanged = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.designsystem.components.preferences
|
||||||
|
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
|
||||||
|
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||||
|
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PreferenceTextField(
|
||||||
|
headline: String,
|
||||||
|
onChange: (String?) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
placeholder: String? = null,
|
||||||
|
value: String? = null,
|
||||||
|
supportingText: String? = null,
|
||||||
|
displayValue: (String?) -> Boolean = { !it.isNullOrBlank() },
|
||||||
|
trailingContent: ListItemContent? = null,
|
||||||
|
validation: (String?) -> Boolean = { true },
|
||||||
|
onValidationErrorMessage: String? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
|
style: ListItemStyle = ListItemStyle.Default,
|
||||||
|
) {
|
||||||
|
var displayTextFieldDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val valueToDisplay = if (displayValue(value)) { value } else supportingText
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
modifier = modifier,
|
||||||
|
headlineContent = { Text(headline) },
|
||||||
|
supportingContent = valueToDisplay?.let { @Composable { Text(it) } },
|
||||||
|
trailingContent = trailingContent,
|
||||||
|
style = style,
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = { displayTextFieldDialog = true }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (displayTextFieldDialog) {
|
||||||
|
TextFieldDialog(
|
||||||
|
title = headline,
|
||||||
|
onSubmit = {
|
||||||
|
onChange(it.takeIf { it.isNotBlank() })
|
||||||
|
displayTextFieldDialog = false
|
||||||
|
},
|
||||||
|
onDismissRequest = { displayTextFieldDialog = false },
|
||||||
|
placeholder = placeholder.orEmpty(),
|
||||||
|
value = value.orEmpty(),
|
||||||
|
validation = validation,
|
||||||
|
onValidationErrorMessage = onValidationErrorMessage,
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TextFieldDialog(
|
||||||
|
title: String,
|
||||||
|
onSubmit: (String) -> Unit,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
value: String?,
|
||||||
|
placeholder: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
validation: (String?) -> Boolean = { true },
|
||||||
|
onValidationErrorMessage: String? = null,
|
||||||
|
autoSelectOnDisplay: Boolean = true,
|
||||||
|
maxLines: Int = 1,
|
||||||
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
var textFieldContents by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||||
|
mutableStateOf(TextFieldValue(value.orEmpty(), selection = TextRange(value.orEmpty().length)))
|
||||||
|
}
|
||||||
|
var error by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
val canSubmit by remember { derivedStateOf { validation(textFieldContents.text) } }
|
||||||
|
ListDialog(
|
||||||
|
title = title,
|
||||||
|
onSubmit = { onSubmit(textFieldContents.text) },
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
enabled = canSubmit,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
TextFieldListItem(
|
||||||
|
placeholder = placeholder.orEmpty(),
|
||||||
|
text = textFieldContents,
|
||||||
|
onTextChanged = {
|
||||||
|
error = if (!validation(it.text)) onValidationErrorMessage else null
|
||||||
|
textFieldContents = it
|
||||||
|
},
|
||||||
|
error = error,
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
keyboardActions = KeyboardActions(onAny = {
|
||||||
|
if (validation(textFieldContents.text)) {
|
||||||
|
onSubmit(textFieldContents.text)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
maxLines = maxLines,
|
||||||
|
modifier = Modifier.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoSelectOnDisplay) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -98,6 +98,16 @@ val SemanticColors.bgSubtleTertiary
|
||||||
val SemanticColors.temporaryColorBgSpecial
|
val SemanticColors.temporaryColorBgSpecial
|
||||||
get() = if (isLight) Color(0xFFE4E8F0) else Color(0xFF3A4048)
|
get() = if (isLight) Color(0xFFE4E8F0) else Color(0xFF3A4048)
|
||||||
|
|
||||||
|
// This color is not present in Semantic color, so put hard-coded value for now
|
||||||
|
val SemanticColors.pinDigitBg
|
||||||
|
get() = if (isLight) {
|
||||||
|
// We want LightDesignTokens.colorGray300
|
||||||
|
Color(0xFFF0F2F5)
|
||||||
|
} else {
|
||||||
|
// We want DarkDesignTokens.colorGray400
|
||||||
|
Color(0xFF26282D)
|
||||||
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ColorAliasesPreview() = ElementPreview {
|
internal fun ColorAliasesPreview() = ElementPreview {
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ internal fun SimpleAlertDialogContent(
|
||||||
thirdButtonText: String? = null,
|
thirdButtonText: String? = null,
|
||||||
onThirdButtonClicked: () -> Unit = {},
|
onThirdButtonClicked: () -> Unit = {},
|
||||||
applyPaddingToContents: Boolean = true,
|
applyPaddingToContents: Boolean = true,
|
||||||
|
enabled: Boolean = true,
|
||||||
icon: @Composable (() -> Unit)? = null,
|
icon: @Composable (() -> Unit)? = null,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
@ -122,6 +123,7 @@ internal fun SimpleAlertDialogContent(
|
||||||
if (submitText != null) {
|
if (submitText != null) {
|
||||||
Button(
|
Button(
|
||||||
text = submitText,
|
text = submitText,
|
||||||
|
enabled = enabled,
|
||||||
size = ButtonSize.Medium,
|
size = ButtonSize.Medium,
|
||||||
onClick = onSubmitClicked,
|
onClick = onSubmitClicked,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -55,4 +55,10 @@ enum class FeatureFlags(
|
||||||
description = "Allow user to lock/unlock the app with a pin code or biometrics",
|
description = "Allow user to lock/unlock the app with a pin code or biometrics",
|
||||||
defaultValue = false,
|
defaultValue = false,
|
||||||
),
|
),
|
||||||
|
InRoomCalls(
|
||||||
|
key = "feature.elementcall",
|
||||||
|
title = "Element call in rooms",
|
||||||
|
description = "Allow user to start or join a call in a room",
|
||||||
|
defaultValue = false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
|
||||||
FeatureFlags.NotificationSettings -> true
|
FeatureFlags.NotificationSettings -> true
|
||||||
FeatureFlags.VoiceMessages -> false
|
FeatureFlags.VoiceMessages -> false
|
||||||
FeatureFlags.PinUnlock -> false
|
FeatureFlags.PinUnlock -> false
|
||||||
|
FeatureFlags.InRoomCalls -> false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ anvil {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation(projects.appconfig)
|
||||||
implementation(projects.libraries.di)
|
implementation(projects.libraries.di)
|
||||||
implementation(libs.dagger)
|
implementation(libs.dagger)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.api.core
|
package io.element.android.libraries.matrix.api.core
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The [UserId] of the currently logged in user.
|
||||||
|
*/
|
||||||
typealias SessionId = UserId
|
typealias SessionId = UserId
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ package io.element.android.libraries.matrix.api.core
|
||||||
import io.element.android.libraries.matrix.api.BuildConfig
|
import io.element.android.libraries.matrix.api.BuildConfig
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [String] holding a valid Matrix user ID.
|
||||||
|
*
|
||||||
|
* https://spec.matrix.org/v1.8/appendices/#user-identifiers
|
||||||
|
*/
|
||||||
@JvmInline
|
@JvmInline
|
||||||
value class UserId(val value: String) : Serializable {
|
value class UserId(val value: String) : Serializable {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
package io.element.android.libraries.matrix.api.permalink
|
package io.element.android.libraries.matrix.api.permalink
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import io.element.android.libraries.matrix.api.config.MatrixConfiguration
|
import io.element.android.appconfig.MatrixConfiguration
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapping of an input URI to a matrix.to compliant URI.
|
* Mapping of an input URI to a matrix.to compliant URI.
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.libraries.matrix.api.permalink
|
package io.element.android.libraries.matrix.api.permalink
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.config.MatrixConfiguration
|
import io.element.android.appconfig.MatrixConfiguration
|
||||||
import io.element.android.libraries.matrix.api.core.MatrixPatterns
|
import io.element.android.libraries.matrix.api.core.MatrixPatterns
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.core.UserId
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||||
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -192,5 +194,27 @@ interface MatrixRoom : Closeable {
|
||||||
progressCallback: ProgressCallback?
|
progressCallback: ProgressCallback?
|
||||||
): Result<MediaUploadHandler>
|
): Result<MediaUploadHandler>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a Widget url to display in a [android.webkit.WebView] given the provided parameters.
|
||||||
|
* @param widgetSettings The widget settings to use.
|
||||||
|
* @param clientId The client id to use. It should be unique per app install.
|
||||||
|
* @param languageTag The language tag to use. If null, the default language will be used.
|
||||||
|
* @param theme The theme to use. If null, the default theme will be used.
|
||||||
|
* @return The resulting url, or a failure.
|
||||||
|
*/
|
||||||
|
suspend fun generateWidgetWebViewUrl(
|
||||||
|
widgetSettings: MatrixWidgetSettings,
|
||||||
|
clientId: String,
|
||||||
|
languageTag: String? = null,
|
||||||
|
theme: String? = null,
|
||||||
|
): Result<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a [MatrixWidgetDriver] for the provided [widgetSettings].
|
||||||
|
* @param widgetSettings The widget settings to use.
|
||||||
|
* @return The resulting [MatrixWidgetDriver], or a failure.
|
||||||
|
*/
|
||||||
|
fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver>
|
||||||
|
|
||||||
override fun close() = destroy()
|
override fun close() = destroy()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.libraries.matrix.api.widget
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface CallWidgetSettingsProvider {
|
||||||
|
fun provide(
|
||||||
|
baseUrl: String,
|
||||||
|
widgetId: String = UUID.randomUUID().toString()
|
||||||
|
): MatrixWidgetSettings
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.api.widget
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface MatrixWidgetDriver : AutoCloseable {
|
||||||
|
val id: String
|
||||||
|
val incomingMessages: Flow<String>
|
||||||
|
|
||||||
|
suspend fun run()
|
||||||
|
suspend fun send(message: String)
|
||||||
|
}
|
||||||
|
|
@ -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.libraries.matrix.api.widget
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
class MatrixWidgetSettings(
|
||||||
|
val id: String,
|
||||||
|
val initAfterContentLoad: Boolean,
|
||||||
|
val rawUrl: String,
|
||||||
|
) : Parcelable {
|
||||||
|
companion object
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,8 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||||
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
|
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||||
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||||
import io.element.android.libraries.matrix.impl.core.toProgressWatcher
|
import io.element.android.libraries.matrix.impl.core.toProgressWatcher
|
||||||
import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
|
import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
|
||||||
import io.element.android.libraries.matrix.impl.media.map
|
import io.element.android.libraries.matrix.impl.media.map
|
||||||
|
|
@ -48,6 +50,8 @@ import io.element.android.libraries.matrix.impl.poll.toInner
|
||||||
import io.element.android.libraries.matrix.impl.room.location.toInner
|
import io.element.android.libraries.matrix.impl.room.location.toInner
|
||||||
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
|
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
|
||||||
import io.element.android.libraries.matrix.impl.util.destroyAll
|
import io.element.android.libraries.matrix.impl.util.destroyAll
|
||||||
|
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
|
||||||
|
import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl
|
||||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
@ -65,6 +69,8 @@ import org.matrix.rustcomponents.sdk.RoomListItem
|
||||||
import org.matrix.rustcomponents.sdk.RoomMember
|
import org.matrix.rustcomponents.sdk.RoomMember
|
||||||
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
|
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
|
||||||
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
|
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
|
||||||
|
import org.matrix.rustcomponents.sdk.WidgetPermissions
|
||||||
|
import org.matrix.rustcomponents.sdk.WidgetPermissionsProvider
|
||||||
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
|
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
|
||||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -478,6 +484,27 @@ class RustMatrixRoom(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun generateWidgetWebViewUrl(
|
||||||
|
widgetSettings: MatrixWidgetSettings,
|
||||||
|
clientId: String,
|
||||||
|
languageTag: String?,
|
||||||
|
theme: String?,
|
||||||
|
) = runCatching {
|
||||||
|
widgetSettings.generateWidgetWebViewUrl(innerRoom, clientId, languageTag, theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> = runCatching {
|
||||||
|
RustWidgetDriver(
|
||||||
|
widgetSettings = widgetSettings,
|
||||||
|
room = innerRoom,
|
||||||
|
widgetPermissionsProvider = object : WidgetPermissionsProvider {
|
||||||
|
override fun acquirePermissions(permissions: WidgetPermissions): WidgetPermissions {
|
||||||
|
return permissions
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun sendAttachment(files: List<File>, handle: () -> SendAttachmentJoinHandle): Result<MediaUploadHandler> {
|
private suspend fun sendAttachment(files: List<File>, handle: () -> SendAttachmentJoinHandle): Result<MediaUploadHandler> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
MediaUploadHandlerImpl(files, handle())
|
MediaUploadHandlerImpl(files, handle())
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.impl.widget
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
|
||||||
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||||
|
import org.matrix.rustcomponents.sdk.VirtualElementCallWidgetOptions
|
||||||
|
import org.matrix.rustcomponents.sdk.newVirtualElementCallWidget
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultCallWidgetSettingsProvider @Inject constructor() : CallWidgetSettingsProvider {
|
||||||
|
override fun provide(baseUrl: String, widgetId: String): MatrixWidgetSettings {
|
||||||
|
val options = VirtualElementCallWidgetOptions(
|
||||||
|
elementCallUrl = baseUrl,
|
||||||
|
widgetId = widgetId,
|
||||||
|
parentUrl = null,
|
||||||
|
hideHeader = null,
|
||||||
|
preload = null,
|
||||||
|
fontScale = null,
|
||||||
|
appPrompt = false,
|
||||||
|
skipLobby = true,
|
||||||
|
confineToRoom = true,
|
||||||
|
fonts = null,
|
||||||
|
analyticsId = null
|
||||||
|
)
|
||||||
|
val rustWidgetSettings = newVirtualElementCallWidget(options)
|
||||||
|
return MatrixWidgetSettings.fromRustWidgetSettings(rustWidgetSettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.libraries.matrix.impl.widget
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||||
|
import org.matrix.rustcomponents.sdk.ClientProperties
|
||||||
|
import org.matrix.rustcomponents.sdk.Room
|
||||||
|
import org.matrix.rustcomponents.sdk.WidgetSettings
|
||||||
|
import org.matrix.rustcomponents.sdk.generateWebviewUrl
|
||||||
|
|
||||||
|
fun MatrixWidgetSettings.toRustWidgetSettings() = WidgetSettings(
|
||||||
|
id = this.id,
|
||||||
|
initAfterContentLoad = this.initAfterContentLoad,
|
||||||
|
rawUrl = this.rawUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun MatrixWidgetSettings.Companion.fromRustWidgetSettings(widgetSettings: WidgetSettings) = MatrixWidgetSettings(
|
||||||
|
id = widgetSettings.id,
|
||||||
|
initAfterContentLoad = widgetSettings.initAfterContentLoad,
|
||||||
|
rawUrl = widgetSettings.rawUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun MatrixWidgetSettings.generateWidgetWebViewUrl(
|
||||||
|
room: Room,
|
||||||
|
clientId: String,
|
||||||
|
languageTag: String? = null,
|
||||||
|
theme: String? = null
|
||||||
|
) = generateWebviewUrl(
|
||||||
|
widgetSettings = this.toRustWidgetSettings(),
|
||||||
|
room = room,
|
||||||
|
props = ClientProperties(
|
||||||
|
clientId = clientId,
|
||||||
|
languageTag = languageTag,
|
||||||
|
theme = theme,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.impl.widget
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||||
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.matrix.rustcomponents.sdk.Room
|
||||||
|
import org.matrix.rustcomponents.sdk.WidgetPermissionsProvider
|
||||||
|
import org.matrix.rustcomponents.sdk.makeWidgetDriver
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
|
class RustWidgetDriver(
|
||||||
|
widgetSettings: MatrixWidgetSettings,
|
||||||
|
private val room: Room,
|
||||||
|
private val widgetPermissionsProvider: WidgetPermissionsProvider,
|
||||||
|
): MatrixWidgetDriver {
|
||||||
|
|
||||||
|
override val incomingMessages = MutableSharedFlow<String>()
|
||||||
|
|
||||||
|
private val driverAndHandle = makeWidgetDriver(widgetSettings.toRustWidgetSettings())
|
||||||
|
private var receiveMessageJob: Job? = null
|
||||||
|
|
||||||
|
private var isRunning = AtomicBoolean(false)
|
||||||
|
|
||||||
|
override val id: String = widgetSettings.id
|
||||||
|
|
||||||
|
override suspend fun run() {
|
||||||
|
// Don't run the driver if it's already running
|
||||||
|
if (!isRunning.compareAndSet(false, true)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val coroutineScope = CoroutineScope(coroutineContext)
|
||||||
|
coroutineScope.launch {
|
||||||
|
// This call will suspend the coroutine while the driver is running, so it needs to be launched separately
|
||||||
|
driverAndHandle.driver.run(room, widgetPermissionsProvider)
|
||||||
|
}
|
||||||
|
receiveMessageJob = coroutineScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
while (isActive) {
|
||||||
|
driverAndHandle.handle.recv()?.let { incomingMessages.emit(it) }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
driverAndHandle.handle.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun send(message: String) {
|
||||||
|
driverAndHandle.handle.send(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
receiveMessageJob?.cancel()
|
||||||
|
driverAndHandle.driver.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -208,8 +208,12 @@ class FakeMatrixClient(
|
||||||
findDmResult = result
|
findDmResult = result
|
||||||
}
|
}
|
||||||
|
|
||||||
fun givenGetRoomResult(roomId: RoomId, result: MatrixRoom) {
|
fun givenGetRoomResult(roomId: RoomId, result: MatrixRoom?) {
|
||||||
getRoomResults[roomId] = result
|
if (result == null) {
|
||||||
|
getRoomResults.remove(roomId)
|
||||||
|
} else {
|
||||||
|
getRoomResults[roomId] = result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun givenSearchUsersResult(searchTerm: String, result: Result<MatrixSearchUserResults>) {
|
fun givenSearchUsersResult(searchTerm: String, result: Result<MatrixSearchUserResults>) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.test
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
|
class FakeMatrixClientProvider(
|
||||||
|
private val getClient: (SessionId) -> Result<MatrixClient> = { Result.success(FakeMatrixClient()) }
|
||||||
|
) : MatrixClientProvider {
|
||||||
|
override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> = getClient(sessionId)
|
||||||
|
}
|
||||||
|
|
@ -36,11 +36,14 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||||
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_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
|
||||||
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
|
||||||
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
||||||
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
|
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
|
||||||
|
import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
|
||||||
import io.element.android.tests.testutils.simulateLongTask
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -92,6 +95,8 @@ class FakeMatrixRoom(
|
||||||
private var sendPollResponseResult = Result.success(Unit)
|
private var sendPollResponseResult = Result.success(Unit)
|
||||||
private var endPollResult = Result.success(Unit)
|
private var endPollResult = Result.success(Unit)
|
||||||
private var progressCallbackValues = emptyList<Pair<Long, Long>>()
|
private var progressCallbackValues = emptyList<Pair<Long, Long>>()
|
||||||
|
private var generateWidgetWebViewUrlResult = Result.success("https://call.element.io")
|
||||||
|
private var getWidgetDriverResult: Result<MatrixWidgetDriver> = Result.success(FakeWidgetDriver())
|
||||||
val editMessageCalls = mutableListOf<Pair<String, String?>>()
|
val editMessageCalls = mutableListOf<Pair<String, String?>>()
|
||||||
|
|
||||||
var sendMediaCount = 0
|
var sendMediaCount = 0
|
||||||
|
|
@ -368,6 +373,15 @@ class FakeMatrixRoom(
|
||||||
progressCallback: ProgressCallback?
|
progressCallback: ProgressCallback?
|
||||||
): Result<MediaUploadHandler> = fakeSendMedia(progressCallback)
|
): Result<MediaUploadHandler> = fakeSendMedia(progressCallback)
|
||||||
|
|
||||||
|
override suspend fun generateWidgetWebViewUrl(
|
||||||
|
widgetSettings: MatrixWidgetSettings,
|
||||||
|
clientId: String,
|
||||||
|
languageTag: String?,
|
||||||
|
theme: String?,
|
||||||
|
): Result<String> = generateWidgetWebViewUrlResult
|
||||||
|
|
||||||
|
override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> = getWidgetDriverResult
|
||||||
|
|
||||||
fun givenLeaveRoomError(throwable: Throwable?) {
|
fun givenLeaveRoomError(throwable: Throwable?) {
|
||||||
this.leaveRoomError = throwable
|
this.leaveRoomError = throwable
|
||||||
}
|
}
|
||||||
|
|
@ -475,6 +489,14 @@ class FakeMatrixRoom(
|
||||||
fun givenProgressCallbackValues(values: List<Pair<Long, Long>>) {
|
fun givenProgressCallbackValues(values: List<Pair<Long, Long>>) {
|
||||||
progressCallbackValues = values
|
progressCallbackValues = values
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun givenGenerateWidgetWebViewUrlResult(result: Result<String>) {
|
||||||
|
generateWidgetWebViewUrlResult = result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun givenGetWidgetDriverResult(result: Result<MatrixWidgetDriver>) {
|
||||||
|
getWidgetDriverResult = result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class SendLocationInvocation(
|
data class SendLocationInvocation(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.test.widget
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
|
||||||
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||||
|
|
||||||
|
class FakeCallWidgetSettingsProvider(
|
||||||
|
private val provideFn: (String, String) -> MatrixWidgetSettings = { _, _ -> MatrixWidgetSettings("id", true, "url") }
|
||||||
|
) : CallWidgetSettingsProvider {
|
||||||
|
|
||||||
|
val providedBaseUrls = mutableListOf<String>()
|
||||||
|
|
||||||
|
override fun provide(baseUrl: String, widgetId: String): MatrixWidgetSettings {
|
||||||
|
providedBaseUrls += baseUrl
|
||||||
|
return provideFn(baseUrl, widgetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.test.widget
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class FakeWidgetDriver(
|
||||||
|
override val id: String = UUID.randomUUID().toString(),
|
||||||
|
) : MatrixWidgetDriver {
|
||||||
|
|
||||||
|
private val _sentMessages = mutableListOf<String>()
|
||||||
|
val sentMessages: List<String> = _sentMessages
|
||||||
|
|
||||||
|
var runCalledCount = 0
|
||||||
|
private set
|
||||||
|
var closeCalledCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
override val incomingMessages = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||||
|
|
||||||
|
override suspend fun run() {
|
||||||
|
runCalledCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun send(message: String) {
|
||||||
|
_sentMessages.add(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
closeCalledCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
fun givenIncomingMessage(message: String) {
|
||||||
|
incomingMessages.tryEmit(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,5 +25,8 @@ interface PreferencesStore {
|
||||||
suspend fun setDeveloperModeEnabled(enabled: Boolean)
|
suspend fun setDeveloperModeEnabled(enabled: Boolean)
|
||||||
fun isDeveloperModeEnabledFlow(): Flow<Boolean>
|
fun isDeveloperModeEnabledFlow(): Flow<Boolean>
|
||||||
|
|
||||||
|
suspend fun setCustomElementCallBaseUrl(string: String?)
|
||||||
|
fun getCustomElementCallBaseUrlFlow(): Flow<String?>
|
||||||
|
|
||||||
suspend fun reset()
|
suspend fun reset()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.preferences.api.store.PreferencesStore
|
import io.element.android.features.preferences.api.store.PreferencesStore
|
||||||
|
|
@ -37,6 +38,7 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
|
||||||
|
|
||||||
private val richTextEditorKey = booleanPreferencesKey("richTextEditor")
|
private val richTextEditorKey = booleanPreferencesKey("richTextEditor")
|
||||||
private val developerModeKey = booleanPreferencesKey("developerMode")
|
private val developerModeKey = booleanPreferencesKey("developerMode")
|
||||||
|
private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl")
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultPreferencesStore @Inject constructor(
|
class DefaultPreferencesStore @Inject constructor(
|
||||||
|
|
@ -71,6 +73,22 @@ class DefaultPreferencesStore @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun setCustomElementCallBaseUrl(string: String?) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
if (string != null) {
|
||||||
|
prefs[customElementCallBaseUrlKey] = string
|
||||||
|
} else {
|
||||||
|
prefs.remove(customElementCallBaseUrlKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCustomElementCallBaseUrlFlow(): Flow<String?> {
|
||||||
|
return store.data.map { prefs ->
|
||||||
|
prefs[customElementCallBaseUrlKey]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun reset() {
|
override suspend fun reset() {
|
||||||
store.edit { it.clear() }
|
store.edit { it.clear() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
class InMemoryPreferencesStore(
|
class InMemoryPreferencesStore(
|
||||||
isRichTextEditorEnabled: Boolean = false,
|
isRichTextEditorEnabled: Boolean = false,
|
||||||
isDeveloperModeEnabled: Boolean = false,
|
isDeveloperModeEnabled: Boolean = false,
|
||||||
|
customElementCallBaseUrl: String? = null,
|
||||||
) : PreferencesStore {
|
) : PreferencesStore {
|
||||||
private var _isRichTextEditorEnabled = MutableStateFlow(isRichTextEditorEnabled)
|
private var _isRichTextEditorEnabled = MutableStateFlow(isRichTextEditorEnabled)
|
||||||
private var _isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
|
private var _isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
|
||||||
|
private var _customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
|
||||||
|
|
||||||
override suspend fun setRichTextEditorEnabled(enabled: Boolean) {
|
override suspend fun setRichTextEditorEnabled(enabled: Boolean) {
|
||||||
_isRichTextEditorEnabled.value = enabled
|
_isRichTextEditorEnabled.value = enabled
|
||||||
|
|
@ -43,6 +45,14 @@ class InMemoryPreferencesStore(
|
||||||
return _isDeveloperModeEnabled
|
return _isDeveloperModeEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun setCustomElementCallBaseUrl(string: String?) {
|
||||||
|
_customElementCallBaseUrl.tryEmit(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCustomElementCallBaseUrlFlow(): Flow<String?> {
|
||||||
|
return _customElementCallBaseUrl
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun reset() {
|
override suspend fun reset() {
|
||||||
// No op
|
// No op
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ package io.element.android.libraries.push.impl
|
||||||
|
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
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.EventId
|
||||||
|
|
@ -28,7 +29,6 @@ import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyReque
|
||||||
import io.element.android.libraries.pushproviders.api.PusherSubscriber
|
import io.element.android.libraries.pushproviders.api.PusherSubscriber
|
||||||
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
|
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
|
||||||
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
|
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
|
||||||
import io.element.android.services.toolbox.api.appname.AppNameProvider
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ private val loggerTag = LoggerTag("PushersManager", LoggerTag.PushLoggerTag)
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class PushersManager @Inject constructor(
|
class PushersManager @Inject constructor(
|
||||||
// private val localeProvider: LocaleProvider,
|
// private val localeProvider: LocaleProvider,
|
||||||
private val appNameProvider: AppNameProvider,
|
private val buildMeta: BuildMeta,
|
||||||
// private val getDeviceInfoUseCase: GetDeviceInfoUseCase,
|
// private val getDeviceInfoUseCase: GetDeviceInfoUseCase,
|
||||||
private val pushGatewayNotifyRequest: PushGatewayNotifyRequest,
|
private val pushGatewayNotifyRequest: PushGatewayNotifyRequest,
|
||||||
private val pushClientSecret: PushClientSecret,
|
private val pushClientSecret: PushClientSecret,
|
||||||
|
|
@ -88,7 +88,7 @@ class PushersManager @Inject constructor(
|
||||||
appId = PushConfig.pusher_app_id,
|
appId = PushConfig.pusher_app_id,
|
||||||
profileTag = DEFAULT_PUSHER_FILE_TAG + "_" /* TODO + abs(activeSessionHolder.getActiveSession().myUserId.hashCode())*/,
|
profileTag = DEFAULT_PUSHER_FILE_TAG + "_" /* TODO + abs(activeSessionHolder.getActiveSession().myUserId.hashCode())*/,
|
||||||
lang = "en", // TODO localeProvider.current().language,
|
lang = "en", // TODO localeProvider.current().language,
|
||||||
appDisplayName = appNameProvider.getAppName(),
|
appDisplayName = buildMeta.applicationName,
|
||||||
deviceDisplayName = "MyDevice", // TODO getDeviceInfoUseCase.execute().displayName().orEmpty(),
|
deviceDisplayName = "MyDevice", // TODO getDeviceInfoUseCase.execute().displayName().orEmpty(),
|
||||||
url = gateway,
|
url = gateway,
|
||||||
defaultPayload = createDefaultPayload(pushClientSecret.getSecretForUser(userId))
|
defaultPayload = createDefaultPayload(pushClientSecret.getSecretForUser(userId))
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,6 @@
|
||||||
<string name="common_room_name_placeholder">"např. název vašeho projektu"</string>
|
<string name="common_room_name_placeholder">"např. název vašeho projektu"</string>
|
||||||
<string name="common_search_for_someone">"Hledat někoho"</string>
|
<string name="common_search_for_someone">"Hledat někoho"</string>
|
||||||
<string name="common_search_results">"Výsledky hledání"</string>
|
<string name="common_search_results">"Výsledky hledání"</string>
|
||||||
<string name="common_secure_backup">"Zabezpečená záloha"</string>
|
|
||||||
<string name="common_security">"Zabezpečení"</string>
|
<string name="common_security">"Zabezpečení"</string>
|
||||||
<string name="common_sending">"Odesílání…"</string>
|
<string name="common_sending">"Odesílání…"</string>
|
||||||
<string name="common_server_not_supported">"Server není podporován"</string>
|
<string name="common_server_not_supported">"Server není podporován"</string>
|
||||||
|
|
@ -208,9 +207,9 @@
|
||||||
<string name="screen_notification_settings_additional_settings_section_title">"Další nastavení"</string>
|
<string name="screen_notification_settings_additional_settings_section_title">"Další nastavení"</string>
|
||||||
<string name="screen_notification_settings_calls_label">"Halsové a video hovory"</string>
|
<string name="screen_notification_settings_calls_label">"Halsové a video hovory"</string>
|
||||||
<string name="screen_notification_settings_configuration_mismatch">"Neshoda konfigurace"</string>
|
<string name="screen_notification_settings_configuration_mismatch">"Neshoda konfigurace"</string>
|
||||||
<string name="screen_notification_settings_configuration_mismatch_description">"Zjednodušili jsme nastavení oznámení, abychom usnadnili hledání možností.
|
<string name="screen_notification_settings_configuration_mismatch_description">"Zjednodušili jsme nastavení oznámení, abychom usnadnili hledání možností.
|
||||||
|
|
||||||
Některá vlastní nastavení, která jste si vybrali v minulosti, se zde nezobrazují, ale jsou stále aktivní.
|
Některá vlastní nastavení, která jste si vybrali v minulosti, se zde nezobrazují, ale jsou stále aktivní.
|
||||||
|
|
||||||
Pokud budete pokračovat, některá nastavení se mohou změnit."</string>
|
Pokud budete pokračovat, některá nastavení se mohou změnit."</string>
|
||||||
<string name="screen_notification_settings_direct_chats">"Přímé zprávy"</string>
|
<string name="screen_notification_settings_direct_chats">"Přímé zprávy"</string>
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,6 @@
|
||||||
<string name="common_room_name_placeholder">"например, название вашего проекта"</string>
|
<string name="common_room_name_placeholder">"например, название вашего проекта"</string>
|
||||||
<string name="common_search_for_someone">"Поиск человека"</string>
|
<string name="common_search_for_someone">"Поиск человека"</string>
|
||||||
<string name="common_search_results">"Результаты поиска"</string>
|
<string name="common_search_results">"Результаты поиска"</string>
|
||||||
<string name="common_secure_backup">"Безопасное резервное копирование"</string>
|
|
||||||
<string name="common_security">"Безопасность"</string>
|
<string name="common_security">"Безопасность"</string>
|
||||||
<string name="common_sending">"Отправка…"</string>
|
<string name="common_sending">"Отправка…"</string>
|
||||||
<string name="common_server_not_supported">"Сервер не поддерживается"</string>
|
<string name="common_server_not_supported">"Сервер не поддерживается"</string>
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,6 @@
|
||||||
<string name="common_room_name_placeholder">"napr. názov vášho projektu"</string>
|
<string name="common_room_name_placeholder">"napr. názov vášho projektu"</string>
|
||||||
<string name="common_search_for_someone">"Vyhľadať niekoho"</string>
|
<string name="common_search_for_someone">"Vyhľadať niekoho"</string>
|
||||||
<string name="common_search_results">"Výsledky hľadania"</string>
|
<string name="common_search_results">"Výsledky hľadania"</string>
|
||||||
<string name="common_secure_backup">"Bezpečné zálohovanie"</string>
|
|
||||||
<string name="common_security">"Bezpečnosť"</string>
|
<string name="common_security">"Bezpečnosť"</string>
|
||||||
<string name="common_sending">"Odosiela sa…"</string>
|
<string name="common_sending">"Odosiela sa…"</string>
|
||||||
<string name="common_server_not_supported">"Server nie je podporovaný"</string>
|
<string name="common_server_not_supported">"Server nie je podporovaný"</string>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue