Merge branch 'develop' into feature/fga/live_location_sharing_setup
This commit is contained in:
commit
e8c2790595
131 changed files with 825 additions and 407 deletions
|
|
@ -154,4 +154,12 @@ enum class FeatureFlags(
|
|||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
),
|
||||
ValidateNetworkWhenSchedulingNotificationFetching(
|
||||
key = "feature.validate_network_when_scheduling_notification_fetching",
|
||||
title = "validate internet connectivity when scheduling notification fetching",
|
||||
description = "Only fetch events for push notifications when the device has internet connectivity. " +
|
||||
"Enabling this can be problematic in air-gapped environments.",
|
||||
defaultValue = { true },
|
||||
isFinished = false,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ class RustEncryptionService(
|
|||
|
||||
override suspend fun recover(recoveryKey: String): Result<Unit> = withContext(dispatchers.io) {
|
||||
runCatchingExceptions {
|
||||
service.recover(recoveryKey)
|
||||
service.recoverAndFixBackup(recoveryKey)
|
||||
}.recoverCatching {
|
||||
when (it) {
|
||||
// We ignore import errors because the user will be notified about them via the "Key storage out of sync" detection.
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ class JoinedRustRoom(
|
|||
|
||||
override suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatchingExceptions {
|
||||
innerRoom.reportContent(eventId = eventId.value, score = null, reason = reason)
|
||||
innerRoom.reportContent(eventId = eventId.value, reason = reason)
|
||||
if (blockUserId != null) {
|
||||
innerRoom.ignoreUser(blockUserId.value)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import io.element.android.libraries.workmanager.api.WorkManagerRequestType
|
|||
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.flow.first
|
||||
import timber.log.Timber
|
||||
|
|
@ -49,7 +48,7 @@ class DefaultPushHandler(
|
|||
private val analyticsService: AnalyticsService,
|
||||
private val systemClock: SystemClock,
|
||||
private val workManagerScheduler: WorkManagerScheduler,
|
||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||
private val syncPendingNotificationsRequestFactory: SyncPendingNotificationsRequestBuilder.Factory,
|
||||
resultProcessor: NotificationResultProcessor,
|
||||
) : PushHandler {
|
||||
init {
|
||||
|
|
@ -134,12 +133,7 @@ class DefaultPushHandler(
|
|||
|
||||
if (!workManagerScheduler.hasPendingWork(userId, WorkManagerRequestType.NOTIFICATION_SYNC)) {
|
||||
Timber.d("No pending worker for push notifications found")
|
||||
workManagerScheduler.submit(
|
||||
SyncPendingNotificationsRequestBuilder(
|
||||
sessionId = userId,
|
||||
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
|
||||
)
|
||||
)
|
||||
workManagerScheduler.submit(syncPendingNotificationsRequestFactory.create(userId))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
|
||||
|
|
|
|||
|
|
@ -160,7 +160,8 @@ class FetchPendingNotificationsWorker(
|
|||
networkTimeoutSpans.finish()
|
||||
|
||||
// If there is a problem with the updated network values, report it and retry if needed
|
||||
if (reportConnectivityError(requests = requests, hasNetwork = hasNetwork, isNetworkBlocked = networkMonitor.isNetworkBlocked())) {
|
||||
val isNetworkBlocked = networkMonitor.isNetworkBlocked.first()
|
||||
if (reportConnectivityError(requests = requests, hasNetwork = hasNetwork, isNetworkBlocked = isNetworkBlocked)) {
|
||||
pushHistoryService.insertOrUpdatePushRequests(requests.map { request ->
|
||||
request.copy(retries = request.retries + 1)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,32 +8,87 @@
|
|||
|
||||
package io.element.android.libraries.push.impl.workmanager
|
||||
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.workDataOf
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder.Companion.SESSION_ID
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerWorkerType
|
||||
import io.element.android.libraries.workmanager.api.workManagerTag
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
import kotlinx.coroutines.flow.first
|
||||
import timber.log.Timber
|
||||
|
||||
interface SyncPendingNotificationsRequestBuilder : WorkManagerRequestBuilder {
|
||||
fun interface Factory {
|
||||
fun create(sessionId: SessionId): SyncPendingNotificationsRequestBuilder
|
||||
}
|
||||
|
||||
class SyncPendingNotificationsRequestBuilder(
|
||||
private val sessionId: SessionId,
|
||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||
) : WorkManagerRequestBuilder {
|
||||
companion object {
|
||||
const val SESSION_ID = "session_id"
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedInject
|
||||
class DefaultSyncPendingNotificationsRequestBuilder(
|
||||
@Assisted private val sessionId: SessionId,
|
||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : SyncPendingNotificationsRequestBuilder {
|
||||
@AssistedFactory
|
||||
@ContributesBinding(AppScope::class)
|
||||
interface Factory : SyncPendingNotificationsRequestBuilder.Factory {
|
||||
override fun create(sessionId: SessionId): DefaultSyncPendingNotificationsRequestBuilder
|
||||
}
|
||||
|
||||
override suspend fun build(): Result<List<WorkManagerRequestWrapper>> {
|
||||
val type = WorkManagerWorkerType.Unique(
|
||||
name = workManagerTag(sessionId = sessionId, requestType = WorkManagerRequestType.NOTIFICATION_SYNC),
|
||||
policy = ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||
)
|
||||
|
||||
val networkRequestBuilder = NetworkRequest.Builder()
|
||||
// Allow any kind of network that can have internet connectivity.
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_VPN)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
// By default, the network request will require the device to not be in VPN, but since some customers use a VPN to connect to their homeserver,
|
||||
// we need to allow VPN networks.
|
||||
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
|
||||
// If we're in an air-gapped environment, we shouldn't validate internet connectivity, as the checker will fail and the worker won't run at all.
|
||||
// Note this will always be false for FOSS, since the feature is only enabled in Element Pro.
|
||||
if (networkMonitor.isInAirGappedEnvironment.first()) {
|
||||
Timber.d("In an air-gapped environment, not adding NET_CAPABILITY_VALIDATED to the network request")
|
||||
networkRequestBuilder.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
} else if (featureFlagService.isFeatureEnabled(FeatureFlags.ValidateNetworkWhenSchedulingNotificationFetching)) {
|
||||
Timber.d("Not in an air-gapped environment, adding NET_CAPABILITY_VALIDATED to the network request")
|
||||
networkRequestBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
}
|
||||
|
||||
val networkConstraints = Constraints.Builder()
|
||||
.setRequiredNetworkRequest(networkRequestBuilder.build(), NetworkType.NOT_REQUIRED)
|
||||
.build()
|
||||
|
||||
val request = OneTimeWorkRequestBuilder<FetchPendingNotificationsWorker>()
|
||||
.setInputData(workDataOf(SESSION_ID to sessionId.value))
|
||||
.apply {
|
||||
|
|
@ -44,8 +99,10 @@ class SyncPendingNotificationsRequestBuilder(
|
|||
setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
}
|
||||
}
|
||||
.setConstraints(networkConstraints)
|
||||
.setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC))
|
||||
.build()
|
||||
|
||||
return Result.success(listOf(WorkManagerRequestWrapper(request, type)))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import io.element.android.libraries.push.impl.history.PushHistoryService
|
|||
import io.element.android.libraries.push.impl.notifications.FakeNotificationResultProcessor
|
||||
import io.element.android.libraries.push.impl.test.DefaultTestPush
|
||||
import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler
|
||||
import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder
|
||||
import io.element.android.libraries.push.test.workmanager.FakeSyncPendingNotificationsRequestBuilder
|
||||
import io.element.android.libraries.pushproviders.api.PushData
|
||||
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
|
||||
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore
|
||||
|
|
@ -34,7 +36,6 @@ import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.Fa
|
|||
import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
|
||||
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
|
|
@ -216,7 +217,6 @@ class DefaultPushHandlerTest {
|
|||
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||
systemClock: FakeSystemClock = FakeSystemClock(),
|
||||
buildVersionSdkIntProvider: FakeBuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33),
|
||||
resultProcessor: FakeNotificationResultProcessor = FakeNotificationResultProcessor(
|
||||
emit = { Result.success(Unit) },
|
||||
start = {},
|
||||
|
|
@ -238,8 +238,10 @@ class DefaultPushHandlerTest {
|
|||
analyticsService = analyticsService,
|
||||
systemClock = systemClock,
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
|
||||
resultProcessor = resultProcessor,
|
||||
syncPendingNotificationsRequestFactory = SyncPendingNotificationsRequestBuilder.Factory {
|
||||
FakeSyncPendingNotificationsRequestBuilder()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.workmanager
|
||||
|
||||
import android.net.NetworkCapabilities
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.hasKeyWithValueOfType
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerWorkerType
|
||||
import io.element.android.libraries.workmanager.api.workManagerTag
|
||||
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DefaultSyncPendingNotificationsRequestBuilderTest {
|
||||
@Test
|
||||
fun `build - success API 33`() = runTest {
|
||||
val request = createSyncPendingNotificationsRequestBuilder(
|
||||
sessionId = A_SESSION_ID,
|
||||
sdkVersion = 33,
|
||||
)
|
||||
|
||||
val results = request.build()
|
||||
assertThat(results.isSuccess).isTrue()
|
||||
results.getOrNull()!!.first().let { result ->
|
||||
assertThat(result.type).isInstanceOf(WorkManagerWorkerType.Unique::class.java)
|
||||
result.request.run {
|
||||
assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java)
|
||||
assertThat(workSpec.input.hasKeyWithValueOfType<String>(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue()
|
||||
assertThat(workSpec.hasConstraints()).isTrue()
|
||||
// True in API 33+
|
||||
assertThat(workSpec.expedited).isTrue()
|
||||
assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - success API 32 and lower`() = runTest {
|
||||
val request = createSyncPendingNotificationsRequestBuilder(
|
||||
sessionId = A_SESSION_ID,
|
||||
sdkVersion = 32,
|
||||
)
|
||||
|
||||
val results = request.build()
|
||||
assertThat(results.isSuccess).isTrue()
|
||||
|
||||
results.getOrNull()!!.first().let { result ->
|
||||
assertThat(result.type).isInstanceOf(WorkManagerWorkerType.Unique::class.java)
|
||||
result.request.run {
|
||||
assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java)
|
||||
assertThat(workSpec.input.hasKeyWithValueOfType<String>(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue()
|
||||
assertThat(workSpec.hasConstraints()).isTrue()
|
||||
// False before API 33
|
||||
assertThat(workSpec.expedited).isFalse()
|
||||
assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - has NET_CAPABILITY_VALIDATED constraint if not in air-gapped env`() = runTest {
|
||||
val request = createSyncPendingNotificationsRequestBuilder(
|
||||
sessionId = A_SESSION_ID,
|
||||
sdkVersion = 33,
|
||||
isInAirGapEnvironment = false,
|
||||
)
|
||||
|
||||
val results = request.build()
|
||||
assertThat(results.isSuccess).isTrue()
|
||||
results.getOrNull()!!.first().let { result ->
|
||||
result.request.run {
|
||||
assertThat(workSpec.hasConstraints()).isTrue()
|
||||
val networkRequest = workSpec.constraints.requiredNetworkRequest
|
||||
assertThat(networkRequest).isNotNull()
|
||||
assertThat(networkRequest!!.capabilities.contains(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - does not have NET_CAPABILITY_VALIDATED constraint if in air-gapped env`() = runTest {
|
||||
val request = createSyncPendingNotificationsRequestBuilder(
|
||||
sessionId = A_SESSION_ID,
|
||||
sdkVersion = 33,
|
||||
isInAirGapEnvironment = true,
|
||||
)
|
||||
|
||||
val results = request.build()
|
||||
assertThat(results.isSuccess).isTrue()
|
||||
results.getOrNull()!!.first().let { result ->
|
||||
result.request.run {
|
||||
assertThat(workSpec.hasConstraints()).isTrue()
|
||||
val networkRequest = workSpec.constraints.requiredNetworkRequest
|
||||
assertThat(networkRequest).isNotNull()
|
||||
assertThat(networkRequest!!.capabilities.contains(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - does not have NET_CAPABILITY_VALIDATED constraint if feature flag is disabled`() = runTest {
|
||||
val request = createSyncPendingNotificationsRequestBuilder(
|
||||
sessionId = A_SESSION_ID,
|
||||
sdkVersion = 33,
|
||||
isInAirGapEnvironment = false,
|
||||
featureFlagService = FakeFeatureFlagService(initialState = mapOf(
|
||||
FeatureFlags.ValidateNetworkWhenSchedulingNotificationFetching.key to false
|
||||
)),
|
||||
)
|
||||
|
||||
val results = request.build()
|
||||
assertThat(results.isSuccess).isTrue()
|
||||
results.getOrNull()!!.first().let { result ->
|
||||
result.request.run {
|
||||
assertThat(workSpec.hasConstraints()).isTrue()
|
||||
val networkRequest = workSpec.constraints.requiredNetworkRequest
|
||||
assertThat(networkRequest).isNotNull()
|
||||
assertThat(networkRequest!!.capabilities.contains(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSyncPendingNotificationsRequestBuilder(
|
||||
sessionId: SessionId,
|
||||
sdkVersion: Int = 33,
|
||||
isInAirGapEnvironment: Boolean = false,
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||
) = DefaultSyncPendingNotificationsRequestBuilder(
|
||||
sessionId = sessionId,
|
||||
buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkVersion),
|
||||
networkMonitor = FakeNetworkMonitor().apply { givenIsInAirGappedEnvironment(isInAirGapEnvironment) },
|
||||
featureFlagService = featureFlagService,
|
||||
)
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.impl.workmanager
|
||||
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.hasKeyWithValueOfType
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerWorkerType
|
||||
import io.element.android.libraries.workmanager.api.workManagerTag
|
||||
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class SyncPendingNotificationsRequestBuilderTest {
|
||||
@Test
|
||||
fun `build - success API 33`() = runTest {
|
||||
val request = createSyncPendingNotificationsRequestBuilder(
|
||||
sessionId = A_SESSION_ID,
|
||||
sdkVersion = 33,
|
||||
)
|
||||
|
||||
val results = request.build()
|
||||
assertThat(results.isSuccess).isTrue()
|
||||
results.getOrNull()!!.first().let { result ->
|
||||
assertThat(result.type).isInstanceOf(WorkManagerWorkerType.Unique::class.java)
|
||||
result.request.run {
|
||||
assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java)
|
||||
assertThat(workSpec.input.hasKeyWithValueOfType<String>(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue()
|
||||
// True in API 33+
|
||||
assertThat(workSpec.expedited).isTrue()
|
||||
assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build - success API 32 and lower`() = runTest {
|
||||
val request = createSyncPendingNotificationsRequestBuilder(
|
||||
sessionId = A_SESSION_ID,
|
||||
sdkVersion = 32,
|
||||
)
|
||||
|
||||
val results = request.build()
|
||||
assertThat(results.isSuccess).isTrue()
|
||||
|
||||
results.getOrNull()!!.first().let { result ->
|
||||
assertThat(result.type).isInstanceOf(WorkManagerWorkerType.Unique::class.java)
|
||||
result.request.run {
|
||||
assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java)
|
||||
assertThat(workSpec.input.hasKeyWithValueOfType<String>(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue()
|
||||
// False before API 33
|
||||
assertThat(workSpec.expedited).isFalse()
|
||||
assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSyncPendingNotificationsRequestBuilder(
|
||||
sessionId: SessionId,
|
||||
sdkVersion: Int = 33,
|
||||
) = SyncPendingNotificationsRequestBuilder(
|
||||
sessionId = sessionId,
|
||||
buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkVersion),
|
||||
)
|
||||
|
|
@ -21,6 +21,7 @@ dependencies {
|
|||
implementation(projects.libraries.push.impl)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.workmanager.api)
|
||||
implementation(projects.tests.testutils)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.coil.compose)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.push.test.workmanager
|
||||
|
||||
import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper
|
||||
|
||||
class FakeSyncPendingNotificationsRequestBuilder(
|
||||
private val build: () -> Result<List<WorkManagerRequestWrapper>> = { Result.success(emptyList()) },
|
||||
) : SyncPendingNotificationsRequestBuilder {
|
||||
override suspend fun build(): Result<List<WorkManagerRequestWrapper>> = build.invoke()
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
package io.element.android.libraries.textcomposer.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -17,14 +18,10 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.LinearGradientShader
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.colors.gradientActionColors
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
|
|
@ -33,7 +30,6 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
|
|||
/**
|
||||
* Send button for the message composer.
|
||||
* Figma: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1956-37575&node-type=frame&m=dev
|
||||
* Temporary Figma : https://www.figma.com/design/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?node-id=2274-39944&m=dev
|
||||
*/
|
||||
@Composable
|
||||
internal fun SendButtonIcon(
|
||||
|
|
@ -49,11 +45,16 @@ internal fun SendButtonIcon(
|
|||
isEditing -> 0.dp
|
||||
else -> 2.dp
|
||||
}
|
||||
val backgroundColor = if (canSendMessage) {
|
||||
ElementTheme.colors.bgAccentRest
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(CircleShape)
|
||||
.size(36.dp)
|
||||
.buttonBackgroundModifier(canSendMessage)
|
||||
.background(backgroundColor)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
|
|
@ -63,11 +64,7 @@ internal fun SendButtonIcon(
|
|||
// Note: accessibility is managed in TextComposer.
|
||||
contentDescription = null,
|
||||
tint = if (canSendMessage) {
|
||||
if (ElementTheme.colors.isLight) {
|
||||
ElementTheme.colors.iconOnSolidPrimary
|
||||
} else {
|
||||
ElementTheme.colors.iconPrimary
|
||||
}
|
||||
ElementTheme.colors.iconOnSolidPrimary
|
||||
} else {
|
||||
ElementTheme.colors.iconQuaternary
|
||||
}
|
||||
|
|
@ -75,31 +72,6 @@ internal fun SendButtonIcon(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Modifier.buttonBackgroundModifier(
|
||||
canSendMessage: Boolean,
|
||||
) = then(
|
||||
if (canSendMessage) {
|
||||
val colors = gradientActionColors()
|
||||
Modifier.drawWithCache {
|
||||
val verticalGradientBrush = ShaderBrush(
|
||||
LinearGradientShader(
|
||||
from = Offset(0f, 0f),
|
||||
to = Offset(0f, size.height),
|
||||
colors = colors,
|
||||
)
|
||||
)
|
||||
onDrawBehind {
|
||||
drawRect(
|
||||
brush = verticalGradientBrush,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SendButtonIconPreview() = ElementPreview {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import io.element.android.libraries.textcomposer.model.SuggestionType
|
|||
import io.element.android.libraries.textcomposer.model.aMarkdownTextEditorState
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorStyle
|
||||
import io.element.android.wysiwyg.compose.internal.applyStyleInCompose
|
||||
import timber.log.Timber
|
||||
|
||||
@Suppress("ModifierMissing")
|
||||
@Composable
|
||||
|
|
@ -149,8 +150,20 @@ fun MarkdownTextInput(
|
|||
|
||||
private fun Editable.checkSuggestionNeeded(): Suggestion? {
|
||||
if (this.isEmpty()) return null
|
||||
val start = Selection.getSelectionStart(this)
|
||||
val end = Selection.getSelectionEnd(this)
|
||||
var start = Selection.getSelectionStart(this)
|
||||
var end = Selection.getSelectionEnd(this)
|
||||
val range = 0..this.length
|
||||
|
||||
if (start !in range || end !in range) {
|
||||
Timber.tag("checkSuggestionNeeded").e("Selection indices are out of bounds: start=$start, end=$end, text length=${this.length}")
|
||||
return null
|
||||
}
|
||||
|
||||
// Make sure the selection order is correct, if not swap them: sometimes we can get the end before the start
|
||||
val tempEnd = end
|
||||
end = maxOf(start, end)
|
||||
start = minOf(start, tempEnd)
|
||||
|
||||
var startOfWord = start
|
||||
while ((startOfWord > 0 || startOfWord == length) && !this[startOfWord - 1].isWhitespace()) {
|
||||
startOfWord--
|
||||
|
|
@ -161,11 +174,16 @@ private fun Editable.checkSuggestionNeeded(): Suggestion? {
|
|||
// If a mention span already exists we don't need suggestions
|
||||
if (getSpans<MentionSpan>(startOfWord, startOfWord + 1).isNotEmpty()) return null
|
||||
|
||||
return if (firstChar in listOf('@', '#', '/')) {
|
||||
return if (firstChar in listOf('@', '#', '/', ':')) {
|
||||
var endOfWord = end
|
||||
while (endOfWord < this.length && !this[endOfWord].isWhitespace()) {
|
||||
endOfWord++
|
||||
}
|
||||
if (startOfWord + 1 > endOfWord) {
|
||||
Timber.tag("checkSuggestionNeeded").e("No need to show suggestions for an invalid range (${startOfWord + 1}..$endOfWord)")
|
||||
return null
|
||||
}
|
||||
|
||||
val text = this.subSequence(startOfWord + 1, endOfWord).toString()
|
||||
val suggestionType = when (firstChar) {
|
||||
'@' -> SuggestionType.Mention
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue