Merge branch 'develop' of https://github.com/vector-im/element-x-android into dla/feature/custom_room_notification_settings_list

This commit is contained in:
David Langley 2023-10-18 22:07:14 +01:00
commit 87b8bfe99d
1981 changed files with 15504 additions and 5063 deletions

View file

@ -24,7 +24,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.designsystem.components.LINK_TAG
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.theme.components.ListItem
@ -70,7 +70,7 @@ fun AnalyticsPreferencesView(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun AnalyticsPreferencesViewPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) =
ElementPreview {

View file

@ -30,7 +30,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Poll
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -45,10 +44,10 @@ import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.api.Config
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.theme.components.Button
@ -56,6 +55,7 @@ import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@ -147,7 +147,7 @@ private fun CheckIcon(modifier: Modifier = Modifier) {
.size(20.dp)
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
.padding(2.dp),
imageVector = Icons.Rounded.Check,
resourceId = CommonDrawables.ic_compound_check,
contentDescription = null,
tint = ElementTheme.colors.textActionAccent,
)
@ -209,7 +209,7 @@ private fun AnalyticsOptInFooter(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun AnalyticsOptInViewPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreview {
AnalyticsOptInView(

View file

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"Wir zeichnen keine persönlichen Daten auf und erstellen keine Profile."</string>
<string name="screen_analytics_prompt_help_us_improve">"Teilen Sie anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."</string>
<string name="screen_analytics_prompt_read_terms">"Sie können alle unsere Bedingungen lesen%1$s."</string>
<string name="screen_analytics_prompt_help_us_improve">"Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."</string>
<string name="screen_analytics_prompt_read_terms">"Du kannst alle unsere Bedingungen lesen %1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"hier"</string>
<string name="screen_analytics_prompt_settings">"Du kannst diese Funktion jederzeit deaktivieren"</string>
<string name="screen_analytics_prompt_third_party_sharing">"Wir geben deine Daten nicht an Dritte weiter"</string>

View file

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"我們不會紀錄或剖繪您的個人資料"</string>
<string name="screen_analytics_prompt_help_us_improve">"分享匿名的使用數據以協助我們釐清問題"</string>
<string name="screen_analytics_prompt_read_terms">"您可以到 %1$s 閱讀我們的條款。"</string>
<string name="screen_analytics_prompt_help_us_improve">"分享匿名的使用數據以協助我們釐清問題"</string>
<string name="screen_analytics_prompt_read_terms">"您可以到%1$s閱讀我們的條款。"</string>
<string name="screen_analytics_prompt_read_terms_content_link">"這裡"</string>
<string name="screen_analytics_prompt_settings">"您可以在任何時候關閉它"</string>
<string name="screen_analytics_prompt_third_party_sharing">"我們不會和第三方分享您的資料"</string>

View file

@ -39,7 +39,6 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="call.element.io" />
@ -53,6 +52,14 @@
<data android:scheme="element" />
<data android:host="call" />
</intent-filter>
<!-- Custom scheme to handle urls from other domains in the format: io.element.call:/?url=https%3A%2F%2Felement.io -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="io.element.call" />
</intent-filter>
</activity>
<service android:name=".CallForegroundService" android:enabled="true" android:foregroundServiceType="mediaPlayback" />

View file

@ -17,29 +17,88 @@
package io.element.android.features.call
import android.net.Uri
import java.net.URLDecoder
import javax.inject.Inject
object CallIntentDataParser {
class CallIntentDataParser @Inject constructor() {
private val validHttpSchemes = sequenceOf("http", "https")
private val validHttpSchemes = sequenceOf("https")
fun parse(data: String?): String? {
val parsedUrl = data?.let { Uri.parse(data) } ?: return null
val scheme = parsedUrl.scheme
return when {
scheme in validHttpSchemes && parsedUrl.host == "call.element.io" -> data
scheme in validHttpSchemes && parsedUrl.host == "call.element.io" -> parsedUrl
scheme == "element" && parsedUrl.host == "call" -> {
// We use this custom scheme to load arbitrary URLs for other instances of Element Call,
// so we can only verify it's an HTTP/HTTPs URL with a non-empty host
parsedUrl.getQueryParameter("url")
?.let { URLDecoder.decode(it, "utf-8") }
?.takeIf {
val internalUri = Uri.parse(it)
internalUri.scheme in validHttpSchemes && !internalUri.host.isNullOrBlank()
}
parsedUrl.getUrlParameter()
}
scheme == "io.element.call" && parsedUrl.host == null -> {
// We use this custom scheme to load arbitrary URLs for other instances of Element Call,
// so we can only verify it's an HTTP/HTTPs URL with a non-empty host
parsedUrl.getUrlParameter()
}
// This should never be possible, but we still need to take into account the possibility
else -> null
}
}?.withCustomParameters()
}
private fun Uri.getUrlParameter(): Uri? {
return getQueryParameter("url")
?.let { urlParameter ->
Uri.parse(urlParameter).takeIf { uri ->
uri.scheme in validHttpSchemes && !uri.host.isNullOrBlank()
}
}
}
}
/**
* Ensure the uri has the following parameters and value in the fragment:
* - appPrompt=false
* - confineToRoom=true
* to ensure that the rendering will bo correct on the embedded Webview.
*/
private fun Uri.withCustomParameters(): String {
val builder = buildUpon()
// Remove the existing query parameters
builder.clearQuery()
queryParameterNames.forEach {
if (it == APP_PROMPT_PARAMETER || it == CONFINE_TO_ROOM_PARAMETER) return@forEach
builder.appendQueryParameter(it, getQueryParameter(it))
}
// Remove the existing fragment parameters, and build the new fragment
val currentFragment = fragment ?: ""
// Reset the current fragment
builder.fragment("")
val queryFragmentPosition = currentFragment.lastIndexOf("?")
val newFragment = if (queryFragmentPosition == -1) {
// No existing query, build it.
"$currentFragment?$APP_PROMPT_PARAMETER=false&$CONFINE_TO_ROOM_PARAMETER=true"
} else {
buildString {
append(currentFragment.substring(0, queryFragmentPosition + 1))
val queryFragment = currentFragment.substring(queryFragmentPosition + 1)
// Replace the existing parameters
val newQueryFragment = queryFragment
.replace("$APP_PROMPT_PARAMETER=true", "$APP_PROMPT_PARAMETER=false")
.replace("$CONFINE_TO_ROOM_PARAMETER=false", "$CONFINE_TO_ROOM_PARAMETER=true")
append(newQueryFragment)
// Ensure the parameters are there
if (!newQueryFragment.contains("$APP_PROMPT_PARAMETER=false")) {
if (newQueryFragment.isNotEmpty()) {
append("&")
}
append("$APP_PROMPT_PARAMETER=false")
}
if (!newQueryFragment.contains("$CONFINE_TO_ROOM_PARAMETER=true")) {
append("&$CONFINE_TO_ROOM_PARAMETER=true")
}
}
}
// We do not want to encode the Fragment part, so append it manually
return builder.build().toString() + "#" + newFragment
}
private const val APP_PROMPT_PARAMETER = "appPrompt"
private const val CONFINE_TO_ROOM_PARAMETER = "confineToRoom"

View file

@ -24,8 +24,6 @@ import android.webkit.WebView
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -33,10 +31,11 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.theme.ElementTheme
typealias RequestPermissionCallback = (Array<String>) -> Unit
@ -58,7 +57,7 @@ internal fun CallScreenView(
title = { Text(stringResource(R.string.element_call)) },
navigationIcon = {
BackButton(
imageVector = Icons.Default.Close,
resourceId = CommonDrawables.ic_compound_close,
onClick = onClose
)
}
@ -138,7 +137,7 @@ private fun WebView.setup(userAgent: String, onPermissionsRequested: (Permission
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun CallScreenViewPreview() {
ElementTheme {

View file

@ -39,6 +39,7 @@ import javax.inject.Inject
class ElementCallActivity : ComponentActivity() {
@Inject lateinit var userAgentProvider: UserAgentProvider
@Inject lateinit var callIntentDataParser: CallIntentDataParser
private lateinit var audioManager: AudioManager
@ -129,7 +130,7 @@ class ElementCallActivity : ComponentActivity() {
finishAndRemoveTask()
}
private fun parseUrl(url: String?): String? = CallIntentDataParser.parse(url)
private fun parseUrl(url: String?): String? = callIntentDataParser.parse(url)
private fun registerPermissionResultLauncher(): ActivityResultLauncher<Array<String>> {
return registerForActivityResult(

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Probíhající hovor"</string>
<string name="call_foreground_service_message_android">"Klepněte pro návrat k hovoru"</string>
<string name="call_foreground_service_title_android">"☎️ Probíhá hovor"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Текущий вызов"</string>
<string name="call_foreground_service_message_android">"Коснитесь, чтобы вернуться к вызову"</string>
<string name="call_foreground_service_title_android">"Идёт вызов"</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_message_android">"點擊以返回到通話頁面"</string>
<string name="call_foreground_service_title_android">"☎️ 通話中"</string>
</resources>

View file

@ -0,0 +1,225 @@
/*
* 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 com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.net.URLEncoder
@RunWith(RobolectricTestRunner::class)
class CallIntentDataParserTest {
private val callIntentDataParser = CallIntentDataParser()
@Test
fun `a null data returns null`() {
val url: String? = null
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `empty data returns null`() {
doTest("", null)
}
@Test
fun `invalid data returns null`() {
doTest("!", null)
}
@Test
fun `data with no scheme returns null`() {
doTest("test", null)
}
@Test
fun `Element Call http urls returns null`() {
doTest("http://call.element.io", null)
doTest("http://call.element.io/some-actual-call?with=parameters", null)
}
@Test
fun `Element Call urls will be returned as is`() {
doTest(
url = "https://call.element.io",
expectedResult = "https://call.element.io#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url param gets url extracted`() {
doTest(
url = VALID_CALL_URL_WITH_PARAM,
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `HTTP and HTTPS urls that don't come from EC return null`() {
doTest("http://app.element.io", null)
doTest("https://app.element.io", null, testEmbedded = false)
doTest("http://", null)
doTest("https://", null)
}
@Test
fun `Element Call url with no url returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "io.element.call:/?no_url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element scheme with no call host returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "element://no-call?url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element scheme with no data returns null`() {
val url = "element://call?url="
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `Element Call url with no data returns null`() {
val url = "io.element.call:/?url="
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element invalid scheme returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "bad.scheme:/?url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `Element Call url with url extra param appPrompt gets url extracted`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}&appPrompt=true",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url extra param in fragment appPrompt gets url extracted`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#?appPrompt=true",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&confineToRoom=true"
)
}
@Test
fun `Element Call url with url extra param in fragment appPrompt and other gets url extracted`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#?appPrompt=true&otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&otherParam=maybe&confineToRoom=true"
)
}
@Test
fun `Element Call url with url extra param confineToRoom gets url extracted`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}&confineToRoom=false",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url extra param in fragment confineToRoom gets url extracted`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#?confineToRoom=false",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&appPrompt=false"
)
}
@Test
fun `Element Call url with url extra param in fragment confineToRoom and more gets url extracted`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#?confineToRoom=false&otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&otherParam=maybe&appPrompt=false"
)
}
@Test
fun `Element Call url with url fragment gets url extracted`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#fragment",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url fragment with params gets url extracted`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#fragment?otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?otherParam=maybe&$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url fragment with other params gets url extracted`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#?otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?otherParam=maybe&$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with empty fragment`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with empty fragment query`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#?",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
private fun doTest(url: String, expectedResult: String?, testEmbedded: Boolean = true) {
// Test direct parsing
assertThat(callIntentDataParser.parse(url)).isEqualTo(expectedResult)
if (testEmbedded) {
// Test embedded url, scheme 1
val encodedUrl = URLEncoder.encode(url, "utf-8")
val urlScheme1 = "element://call?url=$encodedUrl"
assertThat(callIntentDataParser.parse(urlScheme1)).isEqualTo(expectedResult)
// Test embedded url, scheme 2
val urlScheme2 = "io.element.call:/?url=$encodedUrl"
assertThat(callIntentDataParser.parse(urlScheme2)).isEqualTo(expectedResult)
}
}
companion object {
const val VALID_CALL_URL_WITH_PARAM = "https://call.element.io/some-actual-call?with=parameters"
const val EXTRA_PARAMS = "appPrompt=false&confineToRoom=true"
}
}

View file

@ -1,105 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.net.URLEncoder
@RunWith(RobolectricTestRunner::class)
class CallIntentDataParserTests {
@Test
fun `a null data returns null`() {
val url: String? = null
assertThat(CallIntentDataParser.parse(url)).isNull()
}
@Test
fun `empty data returns null`() {
val url = ""
assertThat(CallIntentDataParser.parse(url)).isNull()
}
@Test
fun `invalid data returns null`() {
val url = "!"
assertThat(CallIntentDataParser.parse(url)).isNull()
}
@Test
fun `data with no scheme returns null`() {
val url = "test"
assertThat(CallIntentDataParser.parse(url)).isNull()
}
@Test
fun `Element Call urls will be returned as is`() {
val httpBaseUrl = "http://call.element.io"
val httpCallUrl = "http://call.element.io/some-actual-call?with=parameters"
val httpsBaseUrl = "https://call.element.io"
val httpsCallUrl = "https://call.element.io/some-actual-call?with=parameters"
assertThat(CallIntentDataParser.parse(httpBaseUrl)).isEqualTo(httpBaseUrl)
assertThat(CallIntentDataParser.parse(httpCallUrl)).isEqualTo(httpCallUrl)
assertThat(CallIntentDataParser.parse(httpsBaseUrl)).isEqualTo(httpsBaseUrl)
assertThat(CallIntentDataParser.parse(httpsCallUrl)).isEqualTo(httpsCallUrl)
}
@Test
fun `HTTP and HTTPS urls that don't come from EC return null`() {
val httpBaseUrl = "http://app.element.io"
val httpsBaseUrl = "https://app.element.io"
val httpInvalidUrl = "http://"
val httpsInvalidUrl = "http://"
assertThat(CallIntentDataParser.parse(httpBaseUrl)).isNull()
assertThat(CallIntentDataParser.parse(httpsBaseUrl)).isNull()
assertThat(CallIntentDataParser.parse(httpInvalidUrl)).isNull()
assertThat(CallIntentDataParser.parse(httpsInvalidUrl)).isNull()
}
@Test
fun `element scheme with call host and url param gets url extracted`() {
val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters"
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "element://call?url=$encodedUrl"
assertThat(CallIntentDataParser.parse(url)).isEqualTo(embeddedUrl)
}
@Test
fun `element scheme with call host and no url param returns null`() {
val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters"
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "element://call?no-url=$encodedUrl"
assertThat(CallIntentDataParser.parse(url)).isNull()
}
@Test
fun `element scheme with no call host returns null`() {
val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters"
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "element://no-call?url=$encodedUrl"
assertThat(CallIntentDataParser.parse(url)).isNull()
}
@Test
fun `element scheme with no data returns null`() {
val url = "element://call?url="
assertThat(CallIntentDataParser.parse(url)).isNull()
}
}

View file

@ -48,6 +48,7 @@ dependencies {
implementation(projects.libraries.deeplink)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.usersearch.impl)
implementation(projects.services.analytics.api)
implementation(libs.coil.compose)
@ -64,6 +65,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.tests.testutils)

View file

@ -32,7 +32,7 @@ import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.features.createroom.impl.userlist.UserListEvents
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Scaffold
@ -84,7 +84,7 @@ fun AddPeopleView(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddPeopleViewTopBar(
private fun AddPeopleViewTopBar(
hasSelectedUsers: Boolean,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
@ -109,7 +109,7 @@ fun AddPeopleViewTopBar(
)
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun AddPeopleViewPreview(@PreviewParameter(AddPeopleUserListStateProvider::class) state: UserListState) = ElementPreview {
AddPeopleView(state = state)

View file

@ -31,7 +31,7 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.configureroom.RoomPrivacyItem
import io.element.android.features.createroom.impl.configureroom.roomPrivacyItems
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.RadioButton
@ -57,7 +57,7 @@ fun RoomPrivacyOption(
) {
Icon(
modifier = Modifier.padding(horizontal = 8.dp),
imageVector = roomPrivacyItem.icon,
resourceId = roomPrivacyItem.icon,
contentDescription = "",
tint = MaterialTheme.colorScheme.secondary,
)
@ -90,7 +90,7 @@ fun RoomPrivacyOption(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun RoomPrivacyOptionPreview() = ElementPreview {
val aRoomPrivacyItem = roomPrivacyItems().first()

View file

@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.userlist.UserListEvents
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.UserListStateProvider
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
@ -76,7 +76,7 @@ fun UserListView(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun UserListViewPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) = ElementPreview {
UserListView(state = state)

View file

@ -18,6 +18,7 @@ package io.element.android.features.createroom.impl.configureroom
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
@ -40,6 +41,8 @@ import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@ -52,10 +55,15 @@ class ConfigureRoomPresenter @Inject constructor(
private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor,
private val analyticsService: AnalyticsService,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<ConfigureRoomState> {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false
@Composable
override fun present(): ConfigureRoomState {
val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig())
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
@ -75,6 +83,13 @@ class ConfigureRoomPresenter @Inject constructor(
}
}
LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
pendingPermissionRequest = false
cameraPhotoPicker.launch()
}
}
val localCoroutineScope = rememberCoroutineScope()
val createRoomAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
@ -93,7 +108,12 @@ class ConfigureRoomPresenter @Inject constructor(
is ConfigureRoomEvents.HandleAvatarAction -> {
when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> cameraPhotoPicker.launch()
AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) {
cameraPhotoPicker.launch()
} else {
pendingPermissionRequest = true
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
}
AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
}
}
@ -106,6 +126,7 @@ class ConfigureRoomPresenter @Inject constructor(
config = createRoomConfig.value,
avatarActions = avatarActions,
createRoomAction = createRoomAction.value,
cameraPermissionState = cameraPermissionState,
eventSink = ::handleEvents,
)
}

View file

@ -20,12 +20,14 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList
data class ConfigureRoomState(
val config: CreateRoomConfig,
val avatarActions: ImmutableList<AvatarAction>,
val createRoomAction: Async<RoomId>,
val cameraPermissionState: PermissionsState,
val eventSink: (ConfigureRoomEvents) -> Unit
) {
val isCreateButtonEnabled: Boolean = config.roomName.isNullOrEmpty().not()

View file

@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.persistentListOf
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
@ -41,5 +42,6 @@ fun aConfigureRoomState() = ConfigureRoomState(
config = CreateRoomConfig(),
avatarActions = persistentListOf(),
createRoomAction = Async.Uninitialized,
cameraPermissionState = aPermissionsState(showDialog = false),
eventSink = { },
)

View file

@ -54,7 +54,7 @@ import io.element.android.libraries.designsystem.components.LabelledTextField
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Scaffold
@ -65,6 +65,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@ -73,9 +74,9 @@ import kotlinx.coroutines.launch
@Composable
fun ConfigureRoomView(
state: ConfigureRoomState,
onBackPressed: () -> Unit,
onRoomCreated: (RoomId) -> Unit,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onRoomCreated: (RoomId) -> Unit = {},
) {
val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
@ -172,11 +173,15 @@ fun ConfigureRoomView(
else -> Unit
}
PermissionsView(
state = state.cameraPermissionState,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConfigureRoomToolbar(
private fun ConfigureRoomToolbar(
isNextActionEnabled: Boolean,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
@ -202,7 +207,7 @@ fun ConfigureRoomToolbar(
}
@Composable
fun RoomNameWithAvatar(
private fun RoomNameWithAvatar(
avatarUri: Uri?,
roomName: String,
modifier: Modifier = Modifier,
@ -230,7 +235,7 @@ fun RoomNameWithAvatar(
}
@Composable
fun RoomTopic(
private fun RoomTopic(
topic: String,
modifier: Modifier = Modifier,
onTopicChanged: (String) -> Unit = {},
@ -249,7 +254,7 @@ fun RoomTopic(
}
@Composable
fun RoomPrivacyOptions(
private fun RoomPrivacyOptions(
selected: RoomPrivacy?,
modifier: Modifier = Modifier,
onOptionSelected: (RoomPrivacyItem) -> Unit = {},
@ -273,10 +278,12 @@ private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
})
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun ConfigureRoomViewPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreview {
ConfigureRoomView(
state = state,
onBackPressed = {},
onRoomCreated = {},
)
}

View file

@ -16,19 +16,17 @@
package io.element.android.features.createroom.impl.configureroom
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Public
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import io.element.android.features.createroom.impl.R
import io.element.android.libraries.designsystem.utils.CommonDrawables
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
data class RoomPrivacyItem(
val privacy: RoomPrivacy,
val icon: ImageVector,
@DrawableRes val icon: Int,
val title: String,
val description: String,
)
@ -40,13 +38,13 @@ fun roomPrivacyItems(): ImmutableList<RoomPrivacyItem> {
when (it) {
RoomPrivacy.Private -> RoomPrivacyItem(
privacy = it,
icon = Icons.Outlined.Lock,
icon = CommonDrawables.ic_compound_lock,
title = stringResource(R.string.screen_create_room_private_option_title),
description = stringResource(R.string.screen_create_room_private_option_description),
)
RoomPrivacy.Public -> RoomPrivacyItem(
privacy = it,
icon = Icons.Outlined.Public,
icon = CommonDrawables.ic_compound_public,
title = stringResource(R.string.screen_create_room_public_option_title),
description = stringResource(R.string.screen_create_room_public_option_description),
)

View file

@ -27,8 +27,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@ -41,17 +39,17 @@ import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@ -128,7 +126,7 @@ fun CreateRoomRootView(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateRoomRootViewTopBar(
private fun CreateRoomRootViewTopBar(
modifier: Modifier = Modifier,
onClosePressed: () -> Unit = {},
) {
@ -141,19 +139,16 @@ fun CreateRoomRootViewTopBar(
)
},
navigationIcon = {
IconButton(onClick = onClosePressed) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(id = CommonStrings.action_close),
tint = MaterialTheme.colorScheme.primary,
)
}
BackButton(
resourceId = CommonDrawables.ic_compound_close,
onClick = onClosePressed,
)
}
)
}
@Composable
fun CreateRoomActionButtonsList(
private fun CreateRoomActionButtonsList(
state: CreateRoomRootState,
modifier: Modifier = Modifier,
onNewRoomClicked: () -> Unit = {},
@ -161,12 +156,12 @@ fun CreateRoomActionButtonsList(
) {
Column(modifier = modifier) {
CreateRoomActionButton(
iconRes = VectorIcons.Groups,
iconRes = CommonDrawables.ic_groups,
text = stringResource(id = R.string.screen_create_room_action_create_room),
onClick = onNewRoomClicked,
)
CreateRoomActionButton(
iconRes = VectorIcons.Share,
iconRes = CommonDrawables.ic_compound_share_android,
text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName),
onClick = onInvitePeopleClicked,
)
@ -174,7 +169,7 @@ fun CreateRoomActionButtonsList(
}
@Composable
fun CreateRoomActionButton(
private fun CreateRoomActionButton(
@DrawableRes iconRes: Int,
text: String,
modifier: Modifier = Modifier,
@ -202,7 +197,7 @@ fun CreateRoomActionButton(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun CreateRoomRootViewPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) =
ElementPreview {

View file

@ -4,6 +4,10 @@
<string name="screen_create_room_action_invite_people">"邀請朋友使用 Element"</string>
<string name="screen_create_room_add_people_title">"邀請夥伴"</string>
<string name="screen_create_room_error_creating_room">"建立聊天室時發生錯誤"</string>
<string name="screen_create_room_private_option_description">"聊天室裡的訊息會被加密。聊天室建立後,無法停用加密功能。"</string>
<string name="screen_create_room_private_option_title">"私密聊天室(僅限邀請)"</string>
<string name="screen_create_room_public_option_description">"訊息未加密,任何人都可以查看。您可以在之後啟用加密功能。"</string>
<string name="screen_create_room_public_option_title">"公開聊天室(任何人)"</string>
<string name="screen_create_room_room_name_label">"聊天室名稱"</string>
<string name="screen_create_room_topic_label">"主題(非必填)"</string>
<string name="screen_create_room_title">"建立聊天室"</string>

View file

@ -37,6 +37,8 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.mockk.every
@ -55,6 +57,7 @@ import org.robolectric.RobolectricTestRunner
import java.io.File
private const val AN_URI_FROM_CAMERA = "content://uri_from_camera"
private const val AN_URI_FROM_CAMERA_2 = "content://uri_from_camera_2"
private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery"
@RunWith(RobolectricTestRunner::class)
@ -70,6 +73,7 @@ class ConfigureRoomPresenterTests {
private lateinit var fakePickerProvider: FakePickerProvider
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
private lateinit var fakeAnalyticsService: FakeAnalyticsService
private lateinit var fakePermissionsPresenter: FakePermissionsPresenter
@Before
fun setup() {
@ -79,12 +83,14 @@ class ConfigureRoomPresenterTests {
fakePickerProvider = FakePickerProvider()
fakeMediaPreProcessor = FakeMediaPreProcessor()
fakeAnalyticsService = FakeAnalyticsService()
fakePermissionsPresenter = FakePermissionsPresenter()
presenter = ConfigureRoomPresenter(
dataStore = createRoomDataStore,
matrixClient = fakeMatrixClient,
mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor,
analyticsService = fakeAnalyticsService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(fakePermissionsPresenter),
)
mockkStatic(File::readBytes)
@ -170,8 +176,6 @@ class ConfigureRoomPresenterTests {
// Room avatar
// Pick avatar
fakePickerProvider.givenResult(null)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
// From gallery
val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY)
fakePickerProvider.givenResult(uriFromGallery)
@ -182,10 +186,23 @@ class ConfigureRoomPresenterTests {
// From camera
val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA)
fakePickerProvider.givenResult(uriFromCamera)
assertThat(newState.cameraPermissionState.permissionGranted).isFalse()
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem()
assertThat(newState.cameraPermissionState.showDialog).isTrue()
fakePermissionsPresenter.setPermissionGranted()
newState = awaitItem()
assertThat(newState.cameraPermissionState.permissionGranted).isTrue()
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera)
assertThat(newState.config).isEqualTo(expectedConfig)
// Do it again, no permission is requested
val uriFromCamera2 = Uri.parse(AN_URI_FROM_CAMERA_2)
fakePickerProvider.givenResult(uriFromCamera2)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2)
assertThat(newState.config).isEqualTo(expectedConfig)
// Remove
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.Remove))
newState = awaitItem()

View file

@ -22,7 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.pages.SunsetPage
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
@Composable
@ -45,7 +45,7 @@ fun MigrationScreenView(
)
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun MigrationViewPreview() = ElementPreview {
MigrationScreenView(

View file

@ -65,7 +65,7 @@ class NotificationsOptInPresenter @AssistedInject constructor(
if (notificationsPermissionsState.permissionGranted) {
callback.onNotificationsOptInFinished()
} else {
notificationsPermissionsState.eventSink(PermissionsEvents.OpenSystemDialog)
notificationsPermissionsState.eventSink(PermissionsEvents.RequestPermissions)
}
}
NotificationsOptInEvents.NotNowClicked -> {

View file

@ -28,6 +28,6 @@ open class NotificationsOptInStateProvider : PreviewParameterProvider<Notificati
}
fun aNotificationsOptInState() = NotificationsOptInState(
notificationsPermissionState = aPermissionsState(),
notificationsPermissionState = aPermissionsState(showDialog = false),
eventSink = {}
)

View file

@ -29,8 +29,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -45,11 +43,12 @@ import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@ -80,7 +79,7 @@ private fun NotificationsOptInHeader(
modifier = modifier,
title = stringResource(R.string.screen_notification_optin_title),
subTitle = stringResource(R.string.screen_notification_optin_subtitle),
iconImageVector = Icons.Default.Notifications,
iconResourceId = CommonDrawables.ic_compound_notifications_solid,
)
}
@ -183,7 +182,7 @@ private fun NotificationRow(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun NotificationsOptInViewPreview(
@PreviewParameter(NotificationsOptInStateProvider::class) state: NotificationsOptInState

View file

@ -38,10 +38,10 @@ import androidx.compose.ui.unit.dp
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Text
@ -123,7 +123,7 @@ private fun listItems() = persistentListOf(
),
)
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun WelcomeViewPreview() {
ElementPreview {

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Jedná se o jednorázový proces, prosíme o strpení."</string>
<string name="screen_migration_title">"Nastavení vašeho účtu"</string>
<string name="screen_notification_optin_subtitle">"Nastavení můžete později změnit."</string>
<string name="screen_notification_optin_title">"Povolte oznámení a nezmeškejte žádnou zprávu"</string>
<string name="screen_welcome_bullet_1">"Hovory, hlasování, vyhledávání a další budou přidány koncem tohoto roku."</string>
<string name="screen_welcome_bullet_2">"Historie zpráv šifrovaných místností nebude v této aktualizaci k dispozici."</string>
<string name="screen_welcome_bullet_3">"Rádi bychom se od vás dozvěděli, co si o tom myslíte, dejte nám vědět prostřednictvím stránky s nastavením."</string>

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Это одноразовый процесс, спасибо, что подождали."</string>
<string name="screen_migration_title">"Настройка учетной записи."</string>
<string name="screen_notification_optin_subtitle">"Вы можете изменить настройки позже."</string>
<string name="screen_notification_optin_title">"Разрешите уведомления и никогда не пропустите сообщение"</string>
<string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>
<string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string>

View file

@ -8,6 +8,6 @@
<string name="screen_welcome_bullet_2">"História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii."</string>
<string name="screen_welcome_bullet_3">"Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení."</string>
<string name="screen_welcome_button">"Poďme na to!"</string>
<string name="screen_welcome_subtitle">"Tu je to, čo potrebujete vedieť:"</string>
<string name="screen_welcome_subtitle">"Toto by ste mali vedieť:"</string>
<string name="screen_welcome_title">"Vitajte v %1$s!"</string>
</resources>

View file

@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_title">"設定您的帳號"</string>
<string name="screen_migration_message">"這是一次性的程序,感謝您耐心等候。"</string>
<string name="screen_migration_title">"正在設定您的帳號。"</string>
<string name="screen_welcome_bullet_1">"通話、投票、搜尋等更多功能將在今年登場。"</string>
<string name="screen_welcome_bullet_2">"在這次的更新,您無法查看聊天室內被加密的歷史訊息。"</string>
<string name="screen_welcome_bullet_3">"我們很樂意聽取您的意見,請到設定頁面告訴我們您的想法。"</string>
<string name="screen_welcome_button">"開始吧!"</string>
<string name="screen_welcome_subtitle">"我們有些事想告訴您:"</string>
<string name="screen_welcome_title">"歡迎使用 %1$s"</string>
</resources>

View file

@ -25,6 +25,7 @@ import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -130,11 +131,7 @@ class NotificationsOptInPresenterTests {
permissionStateProvider: PermissionStateProvider = FakePermissionStateProvider(),
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = NotificationsOptInPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permission: String): PermissionsPresenter {
return permissionsPresenter
}
},
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() {
isFinished = true

View file

@ -14,8 +14,6 @@
* limitations under the License.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)

View file

@ -25,10 +25,10 @@ import kotlinx.collections.immutable.ImmutableList
@Immutable
data class InviteListState(
val inviteList: ImmutableList<InviteListInviteSummary>,
val declineConfirmationDialog: InviteDeclineConfirmationDialog = InviteDeclineConfirmationDialog.Hidden,
val acceptedAction: Async<RoomId> = Async.Uninitialized,
val declinedAction: Async<Unit> = Async.Uninitialized,
val eventSink: (InviteListEvents) -> Unit = {}
val declineConfirmationDialog: InviteDeclineConfirmationDialog,
val acceptedAction: Async<RoomId>,
val declinedAction: Async<Unit>,
val eventSink: (InviteListEvents) -> Unit
)
sealed interface InviteDeclineConfirmationDialog {

View file

@ -39,6 +39,10 @@ open class InviteListStateProvider : PreviewParameterProvider<InviteListState> {
internal fun aInviteListState() = InviteListState(
inviteList = aInviteListInviteSummaryList(),
declineConfirmationDialog = InviteDeclineConfirmationDialog.Hidden,
acceptedAction = Async.Uninitialized,
declinedAction = Async.Uninitialized,
eventSink = {},
)
internal fun aInviteListInviteSummaryList(): ImmutableList<InviteListInviteSummary> {

View file

@ -39,7 +39,7 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
@ -111,7 +111,7 @@ fun InviteListView(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun InviteListContent(
private fun InviteListContent(
state: InviteListState,
modifier: Modifier = Modifier,
onBackClicked: () -> Unit = {},
@ -170,7 +170,7 @@ fun InviteListContent(
)
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun InviteListViewPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) = ElementPreview {
InviteListView(state)

View file

@ -45,7 +45,7 @@ import io.element.android.features.invitelist.impl.model.InviteListInviteSummary
import io.element.android.features.invitelist.impl.model.InviteSender
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
@ -77,7 +77,7 @@ internal fun InviteSummaryRow(
}
@Composable
internal fun DefaultInviteSummaryRow(
private fun DefaultInviteSummaryRow(
invite: InviteListInviteSummary,
onAcceptClicked: () -> Unit = {},
onDeclineClicked: () -> Unit = {},
@ -184,7 +184,7 @@ private fun SenderRow(sender: InviteSender) {
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun InviteSummaryRowPreview(@PreviewParameter(InviteListInviteSummaryProvider::class) data: InviteListInviteSummary) = ElementPreview {
InviteSummaryRow(data)

View file

@ -19,10 +19,10 @@ package io.element.android.features.leaveroom.api
import io.element.android.libraries.matrix.api.core.RoomId
data class LeaveRoomState(
val confirmation: Confirmation = Confirmation.Hidden,
val progress: Progress = Progress.Hidden,
val error: Error = Error.Hidden,
val eventSink: (LeaveRoomEvent) -> Unit = {},
val confirmation: Confirmation,
val progress: Progress,
val error: Error,
val eventSink: (LeaveRoomEvent) -> Unit,
) {
sealed interface Confirmation {
data object Hidden : Confirmation

View file

@ -22,32 +22,32 @@ import io.element.android.libraries.matrix.api.core.RoomId
class LeaveRoomStateProvider : PreviewParameterProvider<LeaveRoomState> {
override val values: Sequence<LeaveRoomState>
get() = sequenceOf(
LeaveRoomState(
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
LeaveRoomState(
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Generic(A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
LeaveRoomState(
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
LeaveRoomState(
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
LeaveRoomState(
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Shown,
error = LeaveRoomState.Error.Hidden,
),
LeaveRoomState(
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Shown,
@ -56,3 +56,14 @@ class LeaveRoomStateProvider : PreviewParameterProvider<LeaveRoomState> {
}
private val A_ROOM_ID = RoomId("!aRoomId:aDomain")
fun aLeaveRoomState(
confirmation: LeaveRoomState.Confirmation = LeaveRoomState.Confirmation.Hidden,
progress: LeaveRoomState.Progress = LeaveRoomState.Progress.Hidden,
error: LeaveRoomState.Error = LeaveRoomState.Error.Hidden,
) = LeaveRoomState(
confirmation = confirmation,
progress = progress,
error = error,
eventSink = {},
)

View file

@ -27,7 +27,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@ -107,7 +107,7 @@ private fun LeaveRoomErrorDialog(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun LeaveRoomViewPreview(
@PreviewParameter(LeaveRoomStateProvider::class) state: LeaveRoomState

View file

@ -44,7 +44,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - initial state hides all dialogs`() = runTest {
val presenter = createPresenter()
val presenter = createLeaveRoomPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -57,7 +57,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - show generic confirmation`() = runTest {
val presenter = createPresenter(
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
@ -77,7 +77,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - show private room confirmation`() = runTest {
val presenter = createPresenter(
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
@ -97,7 +97,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - show last user in room confirmation`() = runTest {
val presenter = createPresenter(
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
@ -118,7 +118,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - leaving a room leaves the room`() = runTest {
val roomMembershipObserver = RoomMembershipObserver()
val presenter = createPresenter(
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
@ -140,7 +140,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - show error if leave room fails`() = runTest {
val presenter = createPresenter(
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
@ -164,7 +164,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - show progress indicator while leaving a room`() = runTest {
val presenter = createPresenter(
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
@ -186,7 +186,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - hide error hides the error`() = runTest {
val presenter = createPresenter(
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
@ -212,7 +212,7 @@ class LeaveRoomPresenterImplTest {
}
}
private fun TestScope.createPresenter(
private fun TestScope.createLeaveRoomPresenter(
client: MatrixClient = FakeMatrixClient(),
roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
): LeaveRoomPresenter = LeaveRoomPresenterImpl(

View file

@ -0,0 +1,30 @@
/*
* 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("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.leaveroom.test"
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
api(projects.features.leaveroom.api)
}

View file

@ -20,9 +20,8 @@ import androidx.compose.runtime.Composable
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.leaveroom.api.LeaveRoomState
import javax.inject.Inject
class LeaveRoomPresenterFake @Inject constructor() : LeaveRoomPresenter {
class FakeLeaveRoomPresenter : LeaveRoomPresenter {
val events = mutableListOf<LeaveRoomEvent>()
@ -30,7 +29,12 @@ class LeaveRoomPresenterFake @Inject constructor() : LeaveRoomPresenter {
events += event
}
private var state = LeaveRoomState(eventSink = ::handleEvent)
private var state = LeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
eventSink = ::handleEvent,
)
set(value) {
field = value.copy(eventSink = ::handleEvent)
}

View file

@ -21,7 +21,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@ -37,12 +37,12 @@ import coil.request.ImageRequest
import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.StaticMapUrlBuilder
import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.theme.ElementTheme
import timber.log.Timber
import io.element.android.libraries.designsystem.R as DesignSystemR
/**
* Shows a static map image downloaded via a third party service's static maps API.
@ -64,7 +64,7 @@ fun StaticMapView(
contentAlignment = Alignment.Center
) {
val context = LocalContext.current
var retryHash by remember { mutableStateOf(0) }
var retryHash by remember { mutableIntStateOf(0) }
val builder = remember { StaticMapUrlBuilder(context) }
val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) {
@ -102,7 +102,7 @@ fun StaticMapView(
contentScale = ContentScale.Fit,
)
Icon(
resourceId = DesignSystemR.drawable.pin,
resourceId = CommonDrawables.pin,
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.centerBottomEdge(this),
@ -119,7 +119,7 @@ fun StaticMapView(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun StaticMapViewPreview() = ElementPreview {
StaticMapView(

View file

@ -30,15 +30,15 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.features.location.api.R
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.BooleanProvider
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@ -78,10 +78,10 @@ internal fun StaticMapPlaceholder(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun StaticMapPlaceholderPreview(
@PreviewParameter(BooleanParameterProvider::class) values: Boolean
@PreviewParameter(BooleanProvider::class) values: Boolean
) = ElementPreview {
StaticMapPlaceholder(
showProgress = values,
@ -91,8 +91,3 @@ internal fun StaticMapPlaceholderPreview(
onLoadMapClick = {},
)
}
internal class BooleanParameterProvider : PreviewParameterProvider<Boolean> {
override val values: Sequence<Boolean>
get() = sequenceOf(true, false)
}

View file

@ -17,9 +17,9 @@
package io.element.android.features.location.impl.common.permissions
data class PermissionsState(
val permissions: Permissions = Permissions.NoneGranted,
val shouldShowRationale: Boolean = false,
val eventSink: (PermissionsEvents) -> Unit = {},
val permissions: Permissions,
val shouldShowRationale: Boolean,
val eventSink: (PermissionsEvents) -> Unit,
) {
sealed interface Permissions {
data object AllGranted : Permissions

View file

@ -17,11 +17,11 @@
package io.element.android.features.location.impl.send
data class SendLocationState(
val permissionDialog: Dialog = Dialog.None,
val mode: Mode = Mode.PinLocation,
val hasLocationPermission: Boolean = false,
val appName: String = "AppName",
val eventSink: (SendLocationEvents) -> Unit = {},
val permissionDialog: Dialog,
val mode: Mode,
val hasLocationPermission: Boolean,
val appName: String,
val eventSink: (SendLocationEvents) -> Unit,
) {
sealed interface Mode {
data object SenderLocation : Mode

View file

@ -23,35 +23,44 @@ private const val APP_NAME = "ApplicationName"
class SendLocationStateProvider : PreviewParameterProvider<SendLocationState> {
override val values: Sequence<SendLocationState>
get() = sequenceOf(
SendLocationState(
aSendLocationState(
permissionDialog = SendLocationState.Dialog.None,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = false,
appName = APP_NAME,
),
SendLocationState(
aSendLocationState(
permissionDialog = SendLocationState.Dialog.PermissionDenied,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = false,
appName = APP_NAME,
),
SendLocationState(
aSendLocationState(
permissionDialog = SendLocationState.Dialog.PermissionRationale,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = false,
appName = APP_NAME,
),
SendLocationState(
aSendLocationState(
permissionDialog = SendLocationState.Dialog.None,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = true,
appName = APP_NAME,
),
SendLocationState(
aSendLocationState(
permissionDialog = SendLocationState.Dialog.None,
mode = SendLocationState.Mode.SenderLocation,
hasLocationPermission = true,
appName = APP_NAME,
),
)
}
private fun aSendLocationState(
permissionDialog: SendLocationState.Dialog,
mode: SendLocationState.Mode,
hasLocationPermission: Boolean,
): SendLocationState {
return SendLocationState(
permissionDialog = permissionDialog,
mode = mode,
hasLocationPermission = hasLocationPermission,
appName = APP_NAME,
eventSink = {}
)
}

View file

@ -52,7 +52,7 @@ import io.element.android.features.location.impl.R
import io.element.android.features.location.impl.common.PermissionDeniedDialog
import io.element.android.features.location.impl.common.PermissionRationaleDialog
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
@ -60,13 +60,13 @@ import io.element.android.libraries.designsystem.theme.components.FloatingAction
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.maplibre.compose.CameraMode
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
import io.element.android.libraries.maplibre.compose.MapboxMap
import io.element.android.libraries.maplibre.compose.rememberCameraPositionState
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.designsystem.R as DesignSystemR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@ -203,7 +203,7 @@ fun SendLocationView(
),
)
Icon(
resourceId = DesignSystemR.drawable.pin,
resourceId = CommonDrawables.pin,
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.centerBottomEdge(this),
@ -223,7 +223,7 @@ fun SendLocationView(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun SendLocationViewPreview(
@PreviewParameter(SendLocationStateProvider::class) state: SendLocationState

View file

@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationSearching
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -42,7 +41,7 @@ import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.PermissionDeniedDialog
import io.element.android.features.location.impl.common.PermissionRationaleDialog
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
@ -51,6 +50,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.maplibre.compose.CameraMode
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
import io.element.android.libraries.maplibre.compose.IconAnchor
@ -62,7 +62,6 @@ import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.theme.compound.generated.TypographyTokens
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableMap
import io.element.android.libraries.designsystem.R as DesignSystemR
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
@ -125,7 +124,10 @@ fun ShowLocationView(
},
actions = {
IconButton(onClick = { state.eventSink(ShowLocationEvents.Share) }) {
Icon(imageVector = Icons.Outlined.Share, contentDescription = stringResource(CommonStrings.action_share))
Icon(
resourceId = CommonDrawables.ic_compound_share_android,
contentDescription = stringResource(CommonStrings.action_share),
)
}
}
)
@ -163,7 +165,7 @@ fun ShowLocationView(
MapboxMap(
styleUri = rememberTileStyleUrl(),
modifier = Modifier.fillMaxSize(),
images = mapOf(PIN_ID to DesignSystemR.drawable.pin).toImmutableMap(),
images = mapOf(PIN_ID to CommonDrawables.pin).toImmutableMap(),
cameraPositionState = cameraPositionState,
uiSettings = MapDefaults.uiSettings,
symbolManagerSettings = MapDefaults.symbolManagerSettings,
@ -183,7 +185,7 @@ fun ShowLocationView(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun ShowLocationViewPreview(@PreviewParameter(ShowLocationStateProvider::class) state: ShowLocationState) = ElementPreview {
ShowLocationView(

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.impl
import io.element.android.features.location.impl.common.permissions.PermissionsState
fun aPermissionsState(
permissions: PermissionsState.Permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale: Boolean = false,
): PermissionsState {
return PermissionsState(
permissions = permissions,
shouldShowRationale = shouldShowRationale,
eventSink = {},
)
}

View file

@ -26,7 +26,11 @@ class PermissionsPresenterFake : PermissionsPresenter {
events += event
}
private var state = PermissionsState(eventSink = ::handleEvent)
private var state = PermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
eventSink = ::handleEvent
)
set(value) {
field = value.copy(eventSink = ::handleEvent)
}

View file

@ -22,6 +22,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
@ -32,7 +33,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.SendLocationInvocation
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay
@ -65,7 +66,7 @@ class SendLocationPresenterTest {
@Test
fun `initial state with permissions granted`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
@ -92,7 +93,7 @@ class SendLocationPresenterTest {
@Test
fun `initial state with permissions partially granted`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.SomeGranted,
shouldShowRationale = false,
)
@ -119,7 +120,7 @@ class SendLocationPresenterTest {
@Test
fun `initial state with permissions denied`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
@ -145,7 +146,7 @@ class SendLocationPresenterTest {
@Test
fun `initial state with permissions denied once`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
@ -171,7 +172,7 @@ class SendLocationPresenterTest {
@Test
fun `rationale dialog dismiss`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
@ -202,7 +203,7 @@ class SendLocationPresenterTest {
@Test
fun `rationale dialog continue`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
@ -230,7 +231,7 @@ class SendLocationPresenterTest {
@Test
fun `permission denied dialog dismiss`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
@ -261,7 +262,7 @@ class SendLocationPresenterTest {
@Test
fun `share sender location`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
@ -317,7 +318,7 @@ class SendLocationPresenterTest {
@Test
fun `share pin location`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
@ -373,7 +374,7 @@ class SendLocationPresenterTest {
@Test
fun `composer context passes through analytics`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
@ -419,7 +420,7 @@ class SendLocationPresenterTest {
@Test
fun `open settings activity`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)

View file

@ -21,6 +21,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
@ -55,7 +56,7 @@ class ShowLocationPresenterTest {
@Test
fun `emits initial state with no location permission`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
@ -75,7 +76,7 @@ class ShowLocationPresenterTest {
@Test
fun `emits initial state location permission denied once`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
@ -94,7 +95,7 @@ class ShowLocationPresenterTest {
@Test
fun `emits initial state with location permission`() = runTest {
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted))
permissionsPresenterFake.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -109,7 +110,7 @@ class ShowLocationPresenterTest {
@Test
fun `emits initial state with partial location permission`() = runTest {
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
permissionsPresenterFake.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -137,7 +138,7 @@ class ShowLocationPresenterTest {
@Test
fun `centers on user location`() = runTest {
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted))
permissionsPresenterFake.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -166,7 +167,7 @@ class ShowLocationPresenterTest {
@Test
fun `rationale dialog dismiss`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
@ -197,7 +198,7 @@ class ShowLocationPresenterTest {
@Test
fun `rationale dialog continue`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true,
)
@ -225,7 +226,7 @@ class ShowLocationPresenterTest {
@Test
fun `permission denied dialog dismiss`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
@ -256,7 +257,7 @@ class ShowLocationPresenterTest {
@Test
fun `open settings activity`() = runTest {
permissionsPresenterFake.givenState(
PermissionsState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
)
@ -290,7 +291,6 @@ class ShowLocationPresenterTest {
}
}
companion object {
private const val A_DESCRIPTION = "My happy place"
}

View file

@ -14,17 +14,14 @@
* limitations under the License.
*/
package io.element.android.features.leaveroom.fake
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.libraries.di.SessionScope
@Module
@ContributesTo(SessionScope::class)
interface LeaveRoomPresenterFakeModule {
@Binds
fun leaveRoomPresenter(leaveRoomPresenter: LeaveRoomPresenterFake): LeaveRoomPresenter
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.lockscreen.api"
}
dependencies {
implementation(projects.libraries.architecture)
}

View file

@ -0,0 +1,21 @@
/*
* 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.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
interface LockScreenEntryPoint : SimpleFeatureEntryPoint

View file

@ -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.api
sealed interface LockScreenState {
data object Unlocked : LockScreenState
data object Locked : LockScreenState
}

View file

@ -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.features.lockscreen.api
import kotlinx.coroutines.flow.StateFlow
interface LockScreenStateService {
val state: StateFlow<LockScreenState>
suspend fun entersForeground()
suspend fun entersBackground()
suspend fun unlock()
}

View file

@ -0,0 +1,54 @@
/*
* 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.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.lockscreen.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
api(projects.features.lockscreen.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.cryptography.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.cryptography.test)
testImplementation(projects.libraries.cryptography.impl)
ksp(libs.showkase.processor)
}

View file

@ -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.lockscreen.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultLockScreenEntryPoint @Inject constructor() : LockScreenEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<LockScreenFlowNode>(buildContext)
}
}

View file

@ -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.features.lockscreen.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.lockscreen.impl.auth.PinAuthenticationNode
import io.element.android.features.lockscreen.impl.create.CreatePinNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
class LockScreenFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BackstackNode<LockScreenFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Auth,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Auth : NavTarget
@Parcelize
data object Create : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Auth -> {
createNode<PinAuthenticationNode>(buildContext)
}
NavTarget.Create -> {
createNode<CreatePinNode>(buildContext)
}
}
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
)
}
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.auth
sealed interface PinAuthenticationEvents {
data object Unlock : PinAuthenticationEvents
}

View file

@ -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.auth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class PinAuthenticationNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: PinAuthenticationPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
PinAuthenticationView(
state = state,
modifier = modifier
)
}
}

View file

@ -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.lockscreen.impl.auth
import androidx.compose.runtime.Composable
import io.element.android.features.lockscreen.api.LockScreenStateService
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class PinAuthenticationPresenter @Inject constructor(
private val pinStateService: LockScreenStateService,
private val coroutineScope: CoroutineScope,
) : Presenter<PinAuthenticationState> {
@Composable
override fun present(): PinAuthenticationState {
fun handleEvents(event: PinAuthenticationEvents) {
when (event) {
PinAuthenticationEvents.Unlock -> coroutineScope.launch { pinStateService.unlock() }
}
}
return PinAuthenticationState(
eventSink = ::handleEvents
)
}
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.auth
data class PinAuthenticationState(
val eventSink: (PinAuthenticationEvents) -> Unit
)

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.auth
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class PinAuthenticationStateProvider : PreviewParameterProvider<PinAuthenticationState> {
override val values: Sequence<PinAuthenticationState>
get() = sequenceOf(
aPinAuthenticationState(),
)
}
fun aPinAuthenticationState() = PinAuthenticationState(
eventSink = {}
)

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.auth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Surface
@Composable
fun PinAuthenticationView(
state: PinAuthenticationState,
modifier: Modifier = Modifier,
) {
Surface(modifier) {
HeaderFooterPage(
modifier = Modifier
.systemBarsPadding()
.fillMaxSize(),
header = { PinAuthenticationHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) },
footer = { PinAuthenticationFooter(state) },
)
}
}
@Composable
private fun PinAuthenticationHeader(
modifier: Modifier = Modifier,
) {
IconTitleSubtitleMolecule(
modifier = modifier,
title = "Element X is locked",
subTitle = null,
iconImageVector = Icons.Default.Lock,
)
}
@Composable
private fun PinAuthenticationFooter(state: PinAuthenticationState) {
Button(
modifier = Modifier.fillMaxWidth(),
text = "Unlock",
onClick = {
state.eventSink(PinAuthenticationEvents.Unlock)
}
)
}
@Composable
@PreviewsDayNight
internal fun PinAuthenticationViewPreview(@PreviewParameter(PinAuthenticationStateProvider::class) state: PinAuthenticationState) {
ElementPreview {
PinAuthenticationView(
state = state,
)
}
}

View file

@ -0,0 +1,21 @@
/*
* 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
sealed interface CreatePinEvents {
object MyEvent : CreatePinEvents
}

View file

@ -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
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class CreatePinNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: CreatePinPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
CreatePinView(
state = state,
modifier = modifier
)
}
}

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.create
import androidx.compose.runtime.Composable
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
class CreatePinPresenter @Inject constructor() : Presenter<CreatePinState> {
@Composable
override fun present(): CreatePinState {
fun handleEvents(event: CreatePinEvents) {
when (event) {
CreatePinEvents.MyEvent -> Unit
}
}
return CreatePinState(
eventSink = ::handleEvents
)
}
}

View file

@ -0,0 +1,21 @@
/*
* 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
data class CreatePinState(
val eventSink: (CreatePinEvents) -> Unit
)

View file

@ -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.lockscreen.impl.create
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class CreatePinStateProvider : PreviewParameterProvider<CreatePinState> {
override val values: Sequence<CreatePinState>
get() = sequenceOf(
aCreatePinState(),
// Add other states here
)
}
fun aCreatePinState() = CreatePinState(
eventSink = {}
)

View file

@ -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.features.lockscreen.impl.create
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import timber.log.Timber
@Composable
fun CreatePinView(
state: CreatePinState,
modifier: Modifier = Modifier,
) {
Timber.d("CreatePinView: $state")
Box(modifier, contentAlignment = Alignment.Center) {
Text(
"CreatePin feature view",
color = MaterialTheme.colorScheme.primary,
)
}
}
@Composable
@PreviewsDayNight
internal fun CreatePinViewPreview(@PreviewParameter(CreatePinStateProvider::class) state: CreatePinState) {
ElementPreview {
CreatePinView(
state = state,
)
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.pin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.lockscreen.impl.pin.storage.PinCodeStore
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
import io.element.android.libraries.cryptography.api.EncryptionResult
import io.element.android.libraries.cryptography.api.SecretKeyProvider
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
private const val SECRET_KEY_ALIAS = "SECRET_KEY_ALIAS_PIN_CODE"
@ContributesBinding(AppScope::class)
class DefaultPinCodeManager @Inject constructor(
private val secretKeyProvider: SecretKeyProvider,
private val encryptionDecryptionService: EncryptionDecryptionService,
private val pinCodeStore: PinCodeStore,
) : PinCodeManager {
override suspend fun isPinCodeAvailable(): Boolean {
return pinCodeStore.hasPinCode()
}
override suspend fun createPinCode(pinCode: String) {
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS)
val encryptedPinCode = encryptionDecryptionService.encrypt(secretKey, pinCode.toByteArray()).toBase64()
pinCodeStore.saveEncryptedPinCode(encryptedPinCode)
}
override suspend fun verifyPinCode(pinCode: String): Boolean {
val encryptedPinCode = pinCodeStore.getEncryptedCode() ?: return false
return try {
val secretKey = secretKeyProvider.getOrCreateKey(SECRET_KEY_ALIAS)
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
decryptedPinCode.contentEquals(pinCode.toByteArray())
} catch (failure: Throwable) {
false
}
}
override suspend fun deletePinCode() {
pinCodeStore.deleteEncryptedPinCode()
}
override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
return pinCodeStore.getRemainingPinCodeAttemptsNumber()
}
override suspend fun onWrongPin(): Int {
return pinCodeStore.onWrongPin()
}
override suspend fun resetCounter() {
pinCodeStore.resetCounter()
}
}

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.pin
/**
* This interface is the main interface to manage the pin code.
* Implementation should take care of encrypting the pin code and storing it.
*/
interface PinCodeManager {
/**
* @return true if a pin code is available.
*/
suspend fun isPinCodeAvailable(): Boolean
/**
* Creates a new encrypted pin code.
* @param pinCode the clear pin code to create
*/
suspend fun createPinCode(pinCode: String)
/**
* @return true if the pin code is correct.
*/
suspend fun verifyPinCode(pinCode: String): Boolean
/**
* Deletes the previously created pin code.
*/
suspend fun deletePinCode()
/**
* @return the number of remaining attempts before the pin code is blocked.
*/
suspend fun getRemainingPinCodeAttemptsNumber(): Int
/**
* Should be called when the pin code is incorrect.
* Will decrement the remaining attempts number.
* @return the number of remaining attempts before the pin code is blocked.
*/
suspend fun onWrongPin(): Int
/**
* Resets the counter of attempts for PIN code.
*/
suspend fun resetCounter()
}

View file

@ -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.lockscreen.impl.pin.storage
/**
* Should be implemented by any class that provides access to the encrypted PIN code.
* All methods are suspending in case there are async IO operations involved.
*/
interface EncryptedPinCodeStorage {
/**
* Returns the encrypted PIN code.
*/
suspend fun getEncryptedCode(): String?
/**
* Saves the encrypted PIN code to some persistable storage.
*/
suspend fun saveEncryptedPinCode(pinCode: String)
/**
* Deletes the PIN code from some persistable storage.
*/
suspend fun deleteEncryptedPinCode()
/**
* Returns whether the PIN code is stored or not.
*/
suspend fun hasPinCode(): Boolean
}

View file

@ -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.features.lockscreen.impl.pin.storage
interface PinCodeStore : EncryptedPinCodeStorage {
interface Listener {
fun onPinSetUpChange(isConfigured: Boolean)
}
/**
* Returns the remaining PIN code attempts. When this reaches 0 the PIN code access won't be available for some time.
*/
suspend fun getRemainingPinCodeAttemptsNumber(): Int
/**
* Should decrement the number of remaining PIN code attempts.
* @return The remaining attempts.
*/
suspend fun onWrongPin(): Int
/**
* Resets the counter of attempts for PIN code and biometric access.
*/
suspend fun resetCounter()
/**
* Adds a listener to be notified when the PIN code us created or removed.
*/
fun addListener(listener: Listener)
/**
* Removes a listener to be notified when the PIN code us created or removed.
*/
fun removeListener(listener: Listener)
}

View file

@ -0,0 +1,104 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.pin.storage
import android.content.SharedPreferences
import androidx.core.content.edit
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject
private const val ENCODED_PIN_CODE_KEY = "ENCODED_PIN_CODE_KEY"
private const val REMAINING_PIN_CODE_ATTEMPTS_KEY = "REMAINING_PIN_CODE_ATTEMPTS_KEY"
private const val MAX_PIN_CODE_ATTEMPTS_NUMBER_BEFORE_LOGOUT = 3
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class SharedPreferencesPinCodeStore @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val sharedPreferences: SharedPreferences,
) : PinCodeStore {
private val listeners = CopyOnWriteArrayList<PinCodeStore.Listener>()
private val mutex = Mutex()
override suspend fun getEncryptedCode(): String? = withContext(dispatchers.io) {
sharedPreferences.getString(ENCODED_PIN_CODE_KEY, null)
}
override suspend fun saveEncryptedPinCode(pinCode: String) = withContext(dispatchers.io) {
sharedPreferences.edit {
putString(ENCODED_PIN_CODE_KEY, pinCode)
}
withContext(dispatchers.main) {
listeners.forEach { it.onPinSetUpChange(isConfigured = true) }
}
}
override suspend fun deleteEncryptedPinCode() = withContext(dispatchers.io) {
// Also reset the counters
resetCounter()
sharedPreferences.edit {
remove(ENCODED_PIN_CODE_KEY)
}
withContext(dispatchers.main) {
listeners.forEach { it.onPinSetUpChange(isConfigured = false) }
}
}
override suspend fun hasPinCode(): Boolean = withContext(dispatchers.io) {
sharedPreferences.contains(ENCODED_PIN_CODE_KEY)
}
override suspend fun getRemainingPinCodeAttemptsNumber(): Int = withContext(dispatchers.io) {
mutex.withLock {
sharedPreferences.getInt(REMAINING_PIN_CODE_ATTEMPTS_KEY, MAX_PIN_CODE_ATTEMPTS_NUMBER_BEFORE_LOGOUT)
}
}
override suspend fun onWrongPin(): Int = withContext(dispatchers.io) {
mutex.withLock {
val remaining = getRemainingPinCodeAttemptsNumber() - 1
sharedPreferences.edit {
putInt(REMAINING_PIN_CODE_ATTEMPTS_KEY, remaining)
}
remaining
}
}
override suspend fun resetCounter() = withContext(dispatchers.io) {
mutex.withLock {
sharedPreferences.edit {
remove(REMAINING_PIN_CODE_ATTEMPTS_KEY)
}
}
}
override fun addListener(listener: PinCodeStore.Listener) {
listeners.add(listener)
}
override fun removeListener(listener: PinCodeStore.Listener) {
listeners.remove(listener)
}
}

View file

@ -0,0 +1,65 @@
/*
* 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.state
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.lockscreen.api.LockScreenState
import io.element.android.features.lockscreen.api.LockScreenStateService
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val GRACE_PERIOD_IN_MILLIS = 90 * 1000L
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultLockScreenStateService @Inject constructor(
private val featureFlagService: FeatureFlagService,
) : LockScreenStateService {
private val _lockScreenState = MutableStateFlow<LockScreenState>(LockScreenState.Unlocked)
override val state: StateFlow<LockScreenState> = _lockScreenState
private var lockJob: Job? = null
override suspend fun unlock() {
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)) {
_lockScreenState.value = LockScreenState.Unlocked
}
}
override suspend fun entersForeground() {
lockJob?.cancel()
}
override suspend fun entersBackground() = coroutineScope {
lockJob = launch {
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)) {
delay(GRACE_PERIOD_IN_MILLIS)
_lockScreenState.value = LockScreenState.Locked
}
}
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.pin
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryPinCodeStore
import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService
import io.element.android.libraries.cryptography.test.SimpleSecretKeyProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultPinCodeManagerTest {
private val pinCodeStore = InMemoryPinCodeStore()
private val secretKeyProvider = SimpleSecretKeyProvider()
private val encryptionDecryptionService = AESEncryptionDecryptionService()
private val pinCodeManager = DefaultPinCodeManager(secretKeyProvider, encryptionDecryptionService, pinCodeStore)
@Test
fun `given a pin code when create and delete assert no pin code left`() = runTest {
pinCodeManager.createPinCode("1234")
assertThat(pinCodeManager.isPinCodeAvailable()).isTrue()
pinCodeManager.deletePinCode()
assertThat(pinCodeManager.isPinCodeAvailable()).isFalse()
}
@Test
fun `given a pin code when create and verify with the same pin succeed`() = runTest {
val pinCode = "1234"
pinCodeManager.createPinCode(pinCode)
assertThat(pinCodeManager.verifyPinCode(pinCode)).isTrue()
}
@Test
fun `given a pin code when create and verify with a different pin fails`() = runTest {
pinCodeManager.createPinCode("1234")
assertThat(pinCodeManager.verifyPinCode("1235")).isFalse()
}
}

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.lockscreen.impl.pin.storage
private const val DEFAULT_REMAINING_ATTEMPTS = 3
class InMemoryPinCodeStore : PinCodeStore {
private var pinCode: String? = null
private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS
override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
return remainingAttempts
}
override suspend fun onWrongPin(): Int {
return remainingAttempts--
}
override suspend fun resetCounter() {
remainingAttempts = DEFAULT_REMAINING_ATTEMPTS
}
override fun addListener(listener: PinCodeStore.Listener) {
// no-op
}
override fun removeListener(listener: PinCodeStore.Listener) {
// no-op
}
override suspend fun getEncryptedCode(): String? {
return pinCode
}
override suspend fun saveEncryptedPinCode(pinCode: String) {
this.pinCode = pinCode
}
override suspend fun deleteEncryptedPinCode() {
pinCode = null
}
override suspend fun hasPinCode(): Boolean {
return pinCode != null
}
}

View file

@ -16,8 +16,9 @@
package io.element.android.features.login.impl.accountprovider
data class AccountProvider constructor(
val title: String,
data class AccountProvider(
val url: String,
val title: String = url.removePrefix("https://").removePrefix("http://"),
val subtitle: String? = null,
val isPublic: Boolean = false,
val isMatrixOrg: Boolean = false,

View file

@ -17,6 +17,7 @@
package io.element.android.features.login.impl.accountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.util.LoginConstants
open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> {
override val values: Sequence<AccountProvider>
@ -31,7 +32,7 @@ open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> {
}
fun anAccountProvider() = AccountProvider(
title = "matrix.org",
url = LoginConstants.MATRIX_ORG_URL,
subtitle = "Matrix.org is an open network for secure, decentralized communication.",
isPublic = true,
isMatrixOrg = true,

View file

@ -23,8 +23,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -35,11 +33,12 @@ import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.theme.ElementTheme
/**
@ -75,7 +74,7 @@ fun AccountProviderView(
} else {
RoundedIconAtom(
size = RoundedIconAtomSize.Medium,
imageVector = Icons.Filled.Search,
resourceId = CommonDrawables.ic_compound_search,
tint = MaterialTheme.colorScheme.primary,
)
}
@ -111,7 +110,7 @@ fun AccountProviderView(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun AccountProviderViewPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) = ElementPreview {
AccountProviderView(

View file

@ -63,7 +63,7 @@ class ChangeServerPresenter @Inject constructor(
changeServerAction: MutableState<Async<Unit>>,
) = launch {
suspend {
authenticationService.setHomeserver(data.title).map {
authenticationService.setHomeserver(data.url).map {
authenticationService.getHomeserverDetails().value!!
// Valid, remember user choice
accountProviderDataSource.userSelection(data)

View file

@ -25,7 +25,7 @@ import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
@Composable
@ -68,7 +68,7 @@ fun ChangeServerView(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun ChangeServerViewPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = ElementPreview {
ChangeServerView(

View file

@ -21,6 +21,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.login.impl.R
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@ -39,3 +41,12 @@ internal fun SlidingSyncNotSupportedDialog(
content = stringResource(R.string.screen_change_server_error_no_sliding_sync_message),
)
}
@PreviewsDayNight
@Composable
internal fun SlidingSyncNotSupportedDialogPreview() = ElementPreview {
SlidingSyncNotSupportedDialog(
onLearnMoreClicked = {},
onDismiss = {},
)
}

View file

@ -33,7 +33,7 @@ import io.element.android.features.login.impl.oidc.OidcUrlParser
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
@ -97,7 +97,7 @@ fun OidcView(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun OidcViewPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) = ElementPreview {
OidcView(

View file

@ -19,6 +19,7 @@ package io.element.android.features.login.impl.screens.changeaccountprovider
import androidx.compose.runtime.Composable
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
@ -33,7 +34,7 @@ class ChangeAccountProviderPresenter @Inject constructor(
// Just matrix.org by default for now
accountProviders = listOf(
AccountProvider(
title = "matrix.org",
url = LoginConstants.MATRIX_ORG_URL,
subtitle = null,
isPublic = true,
isMatrixOrg = true,

View file

@ -45,7 +45,7 @@ import io.element.android.features.login.impl.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@ -109,6 +109,7 @@ fun ChangeAccountProviderView(
// Other
AccountProviderView(
item = AccountProvider(
url = "",
title = stringResource(id = R.string.screen_change_account_provider_other),
),
onClick = onOtherProviderClicked
@ -124,7 +125,7 @@ fun ChangeAccountProviderView(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun ChangeAccountProviderViewPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = ElementPreview {
ChangeAccountProviderView(

View file

@ -76,7 +76,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
fun handleEvents(event: ConfirmAccountProviderEvents) {
when (event) {
ConfirmAccountProviderEvents.Continue -> {
localCoroutineScope.submit(accountProvider.title, loginFlowAction)
localCoroutineScope.submit(accountProvider.url, loginFlowAction)
}
ConfirmAccountProviderEvents.ClearError -> loginFlowAction.value = Async.Uninitialized
}

View file

@ -27,7 +27,7 @@ data class ConfirmAccountProviderState(
val loginFlow: Async<LoginFlow>,
val eventSink: (ConfirmAccountProviderEvents) -> Unit
) {
val submitEnabled: Boolean get() = accountProvider.title.isNotEmpty() && (loginFlow is Async.Uninitialized || loginFlow is Async.Loading)
val submitEnabled: Boolean get() = accountProvider.url.isNotEmpty() && (loginFlow is Async.Uninitialized || loginFlow is Async.Loading)
}
sealed interface LoginFlow {

View file

@ -36,13 +36,14 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMo
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun ConfirmAccountProviderView(
@ -86,7 +87,7 @@ fun ConfirmAccountProviderView(
footer = {
ButtonColumnMolecule {
Button(
text = stringResource(id = R.string.screen_account_provider_continue),
text = stringResource(id = CommonStrings.action_continue),
showProgress = isLoading,
onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) },
enabled = state.submitEnabled || isLoading,
@ -138,7 +139,7 @@ fun ConfirmAccountProviderView(
}
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun ConfirmAccountProviderViewPreview(
@PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState

View file

@ -31,9 +31,6 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
@ -61,7 +58,7 @@ import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubti
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon
@ -72,6 +69,7 @@ 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.autofill
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.theme.ElementTheme
@ -141,7 +139,7 @@ fun LoginPasswordView(
Spacer(modifier = Modifier.weight(1f))
// Submit
Button(
text = stringResource(R.string.screen_login_submit),
text = stringResource(CommonStrings.action_continue),
showProgress = isLoading,
onClick = ::submit,
enabled = state.submitEnabled || isLoading,
@ -169,7 +167,7 @@ fun LoginPasswordView(
@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun LoginForm(
private fun LoginForm(
state: LoginPasswordState,
isLoading: Boolean,
onSubmit: () -> Unit,
@ -201,7 +199,7 @@ internal fun LoginForm(
eventSink(LoginPasswordEvents.SetLogin(it))
}),
placeholder = {
Text(text = stringResource(R.string.screen_login_username_hint))
Text(text = stringResource(CommonStrings.common_username))
},
onValueChange = {
loginFieldState = it
@ -220,7 +218,7 @@ internal fun LoginForm(
IconButton(onClick = {
loginFieldState = ""
}) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(CommonStrings.action_clear))
Icon(resourceId = CommonDrawables.ic_compound_close, contentDescription = stringResource(CommonStrings.action_clear))
}
}
} else null,
@ -248,17 +246,17 @@ internal fun LoginForm(
eventSink(LoginPasswordEvents.SetPassword(it))
},
placeholder = {
Text(text = stringResource(R.string.screen_login_password_hint))
Text(text = stringResource(CommonStrings.common_password))
},
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val image =
if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
if (passwordVisible) CommonDrawables.ic_compound_visibility_on else CommonDrawables.ic_compound_visibility_off
val description =
if (passwordVisible) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password)
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(imageVector = image, description)
Icon(resourceId = image, description)
}
},
keyboardOptions = KeyboardOptions(
@ -274,7 +272,7 @@ internal fun LoginForm(
}
@Composable
internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
private fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
ErrorDialog(
title = stringResource(id = CommonStrings.dialog_title_error),
content = stringResource(loginError(error)),
@ -282,7 +280,7 @@ internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
)
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun LoginPasswordViewPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) = ElementPreview {
LoginPasswordView(

View file

@ -19,6 +19,7 @@ package io.element.android.features.login.impl.screens.searchaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async
open class SearchAccountProviderStateProvider : PreviewParameterProvider<SearchAccountProviderState> {
@ -49,7 +50,7 @@ fun aHomeserverDataList(): List<HomeserverData> {
}
fun aHomeserverData(
homeserverUrl: String = "https://matrix.org",
homeserverUrl: String = LoginConstants.MATRIX_ORG_URL,
isWellknownValid: Boolean = true,
supportSlidingSync: Boolean = true,
): HomeserverData {

View file

@ -32,9 +32,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@ -57,18 +54,20 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderVie
import io.element.android.features.login.impl.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
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.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@ -105,7 +104,7 @@ fun SearchAccountProviderView(
item {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp),
iconImageVector = Icons.Filled.Search,
iconResourceId = CommonDrawables.ic_compound_search,
title = stringResource(id = R.string.screen_account_provider_form_title),
subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle),
)
@ -141,7 +140,7 @@ fun SearchAccountProviderView(
eventSink(SearchAccountProviderEvents.UserInput(""))
}) {
Icon(
imageVector = Icons.Filled.Close,
resourceId = CommonDrawables.ic_compound_close,
contentDescription = stringResource(CommonStrings.action_clear)
)
}
@ -197,9 +196,9 @@ fun SearchAccountProviderView(
@Composable
private fun HomeserverData.toAccountProvider(): AccountProvider {
val isMatrixOrg = homeserverUrl == "https://matrix.org"
val isMatrixOrg = homeserverUrl == LoginConstants.MATRIX_ORG_URL
return AccountProvider(
title = homeserverUrl.removePrefix("http://").removePrefix("https://"),
url = homeserverUrl,
subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null,
isPublic = isMatrixOrg, // There is no need to know for other servers right now
isMatrixOrg = isMatrixOrg,
@ -208,7 +207,7 @@ private fun HomeserverData.toAccountProvider(): AccountProvider {
)
}
@DayNightPreviews
@PreviewsDayNight
@Composable
internal fun SearchAccountProviderViewPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = ElementPreview {
SearchAccountProviderView(

View file

@ -18,6 +18,7 @@ package io.element.android.features.login.impl.screens.waitlistscreen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -58,14 +59,14 @@ class WaitListPresenter @AssistedInject constructor(
mutableStateOf(Async.Uninitialized)
}
val attemptNumber: MutableState<Int> = remember { mutableStateOf(0) }
val attemptNumber = remember { mutableIntStateOf(0) }
fun handleEvents(event: WaitListEvents) {
when (event) {
WaitListEvents.AttemptLogin -> {
// Do not attempt to login on first resume of the View.
attemptNumber.value++
if (attemptNumber.value > 1) {
attemptNumber.intValue++
if (attemptNumber.intValue > 1) {
coroutineScope.loginAttempt(formState, loginAction)
}
}

Some files were not shown because too many files have changed in this diff Show more