Merge branch 'develop' into valere/rtc/voice_call

This commit is contained in:
Valere 2026-03-04 13:46:54 +01:00
commit 04a9c677fb
187 changed files with 2612 additions and 2314 deletions

View file

@ -57,6 +57,7 @@ dependencies {
testCommonDependencies(libs) testCommonDependencies(libs)
testImplementation(projects.features.login.test) testImplementation(projects.features.login.test)
testImplementation(projects.features.share.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oidc.test) testImplementation(projects.libraries.oidc.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)

View file

@ -8,7 +8,6 @@
package io.element.android.appnav package io.element.android.appnav
import android.content.Intent
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -63,6 +62,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.share.api.ShareIntentData
import io.element.android.features.startchat.api.StartChatEntryPoint import io.element.android.features.startchat.api.StartChatEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
@ -307,7 +307,7 @@ class LoggedInFlowNode(
data object RoomDirectory : NavTarget data object RoomDirectory : NavTarget
@Parcelize @Parcelize
data class IncomingShare(val intent: Intent) : NavTarget data class IncomingShare(val shareIntentData: ShareIntentData) : NavTarget
@Parcelize @Parcelize
data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
@ -570,7 +570,7 @@ class LoggedInFlowNode(
shareEntryPoint.createNode( shareEntryPoint.createNode(
parentNode = this, parentNode = this,
buildContext = buildContext, buildContext = buildContext,
params = ShareEntryPoint.Params(intent = navTarget.intent), params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData),
callback = object : ShareEntryPoint.Callback { callback = object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) { override fun onDone(roomIds: List<RoomId>) {
// Remove the incoming share screen // Remove the incoming share screen
@ -649,13 +649,13 @@ class LoggedInFlowNode(
} }
} }
internal suspend fun attachIncomingShare(intent: Intent) { internal suspend fun attachIncomingShare(shareIntentData: ShareIntentData) {
waitForNavTargetAttached { navTarget -> waitForNavTargetAttached { navTarget ->
navTarget is NavTarget.Home navTarget is NavTarget.Home
} }
attachChild<Node> { attachChild<Node> {
backstack.push( backstack.push(
NavTarget.IncomingShare(intent) NavTarget.IncomingShare(shareIntentData)
) )
} }
} }

View file

@ -44,6 +44,7 @@ import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.share.api.ShareIntentData
import io.element.android.features.signedout.api.SignedOutEntryPoint import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
@ -265,7 +266,7 @@ class RootFlowNode(
@Parcelize data class AccountSelect( @Parcelize data class AccountSelect(
val currentSessionId: SessionId, val currentSessionId: SessionId,
val intent: Intent?, val shareIntentData: ShareIntentData?,
val permalinkData: PermalinkData?, val permalinkData: PermalinkData?,
) : NavTarget ) : NavTarget
@ -357,8 +358,8 @@ class RootFlowNode(
backstack.pop() backstack.pop()
} }
attachSession(sessionId).apply { attachSession(sessionId).apply {
if (navTarget.intent != null) { if (navTarget.shareIntentData != null) {
attachIncomingShare(navTarget.intent) attachIncomingShare(navTarget.shareIntentData)
} else if (navTarget.permalinkData != null) { } else if (navTarget.permalinkData != null) {
attachPermalinkData(navTarget.permalinkData) attachPermalinkData(navTarget.permalinkData)
} }
@ -392,7 +393,7 @@ class RootFlowNode(
is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params) is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params)
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction) is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData) is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)
is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.intent) is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.shareIntentData)
} }
} }
@ -423,7 +424,7 @@ class RootFlowNode(
} }
} }
private suspend fun onIncomingShare(intent: Intent) { private suspend fun onIncomingShare(shareIntentData: ShareIntentData) {
// Is there a session already? // Is there a session already?
val latestSessionId = sessionStore.getLatestSessionId() val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) { if (latestSessionId == null) {
@ -437,13 +438,13 @@ class RootFlowNode(
backstack.push( backstack.push(
NavTarget.AccountSelect( NavTarget.AccountSelect(
currentSessionId = latestSessionId, currentSessionId = latestSessionId,
intent = intent, shareIntentData = shareIntentData,
permalinkData = null, permalinkData = null,
) )
) )
} else { } else {
// Only one account, directly attach the incoming share node. // Only one account, directly attach the incoming share node.
loggedInFlowNode.attachIncomingShare(intent) loggedInFlowNode.attachIncomingShare(shareIntentData)
} }
} }
} }
@ -467,7 +468,7 @@ class RootFlowNode(
backstack.push( backstack.push(
NavTarget.AccountSelect( NavTarget.AccountSelect(
currentSessionId = latestSessionId, currentSessionId = latestSessionId,
intent = null, shareIntentData = null,
permalinkData = permalinkData, permalinkData = permalinkData,
) )
) )

View file

@ -12,6 +12,8 @@ import android.content.Intent
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.login.api.LoginIntentResolver import io.element.android.features.login.api.LoginIntentResolver
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
import io.element.android.features.share.api.ShareIntentData
import io.element.android.features.share.api.ShareIntentHandler
import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.deeplink.api.DeeplinkParser import io.element.android.libraries.deeplink.api.DeeplinkParser
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
@ -25,7 +27,7 @@ sealed interface ResolvedIntent {
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
data class Login(val params: LoginParams) : ResolvedIntent data class Login(val params: LoginParams) : ResolvedIntent
data class IncomingShare(val intent: Intent) : ResolvedIntent data class IncomingShare(val shareIntentData: ShareIntentData) : ResolvedIntent
} }
@Inject @Inject
@ -34,6 +36,7 @@ class IntentResolver(
private val loginIntentResolver: LoginIntentResolver, private val loginIntentResolver: LoginIntentResolver,
private val oidcIntentResolver: OidcIntentResolver, private val oidcIntentResolver: OidcIntentResolver,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val shareIntentHandler: ShareIntentHandler,
) { ) {
fun resolve(intent: Intent): ResolvedIntent? { fun resolve(intent: Intent): ResolvedIntent? {
if (intent.canBeIgnored()) return null if (intent.canBeIgnored()) return null
@ -62,7 +65,8 @@ class IntentResolver(
if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData) if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData)
if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) { if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) {
return ResolvedIntent.IncomingShare(intent) val data = shareIntentHandler.handleIncomingShareIntent(intent) ?: return null
return ResolvedIntent.IncomingShare(data)
} }
// Unknown intent // Unknown intent

View file

@ -15,6 +15,9 @@ import androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.test.FakeLoginIntentResolver import io.element.android.features.login.test.FakeLoginIntentResolver
import io.element.android.features.share.api.ShareIntentData
import io.element.android.features.share.api.UriToShare
import io.element.android.features.share.test.FakeShareIntentHandler
import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
@ -239,26 +242,34 @@ class IntentResolverTest {
@Test @Test
fun `test incoming share simple`() { fun `test incoming share simple`() {
val shareIntentData = ShareIntentData.PlainText("Hello")
val sut = createIntentResolver( val sut = createIntentResolver(
oidcIntentResolverResult = { null }, oidcIntentResolverResult = { null },
onIncomingShareIntent = { shareIntentData },
) )
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, "Hello")
} }
val result = sut.resolve(intent) val result = sut.resolve(intent)
assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent)) assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(shareIntentData))
} }
@Test @Test
fun `test incoming share multiple`() { fun `test incoming share multiple`() {
val fileUri = "content://com.example.app/file1.jpg".toUri()
val shareIntentData = ShareIntentData.Uris(text = "Hello", uris = listOf(UriToShare(fileUri, "image/jpg")))
val sut = createIntentResolver( val sut = createIntentResolver(
oidcIntentResolverResult = { null }, oidcIntentResolverResult = { null },
onIncomingShareIntent = { shareIntentData },
) )
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_SEND_MULTIPLE action = Intent.ACTION_SEND_MULTIPLE
putExtra(Intent.EXTRA_TEXT, "Hello")
data = fileUri
} }
val result = sut.resolve(intent) val result = sut.resolve(intent)
assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent)) assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(shareIntentData))
} }
@Test @Test
@ -296,6 +307,7 @@ class IntentResolverTest {
permalinkParserResult: (String) -> PermalinkData = { lambdaError() }, permalinkParserResult: (String) -> PermalinkData = { lambdaError() },
loginIntentResolverResult: (String) -> LoginParams? = { lambdaError() }, loginIntentResolverResult: (String) -> LoginParams? = { lambdaError() },
oidcIntentResolverResult: (Intent) -> OidcAction? = { lambdaError() }, oidcIntentResolverResult: (Intent) -> OidcAction? = { lambdaError() },
onIncomingShareIntent: (Intent) -> ShareIntentData? = { null },
): IntentResolver { ): IntentResolver {
return IntentResolver( return IntentResolver(
deeplinkParser = { deeplinkParserResult }, deeplinkParser = { deeplinkParserResult },
@ -308,6 +320,9 @@ class IntentResolverTest {
permalinkParser = FakePermalinkParser( permalinkParser = FakePermalinkParser(
result = permalinkParserResult result = permalinkParserResult
), ),
shareIntentHandler = FakeShareIntentHandler(
onIncomingShareIntent = onIncomingShareIntent,
),
) )
} }
} }

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -149,7 +150,7 @@ class LogoutPresenterTest {
@Test @Test
fun `present - logout then confirm`() = runTest { fun `present - logout then confirm`() = runTest {
val cancelWorkManagerJobsLambda = lambdaRecorder<SessionId, Unit> {} val cancelWorkManagerJobsLambda = lambdaRecorder<SessionId, WorkManagerRequestType?, Unit> { _, _ -> }
val workManagerScheduler = FakeWorkManagerScheduler(cancelLambda = cancelWorkManagerJobsLambda) val workManagerScheduler = FakeWorkManagerScheduler(cancelLambda = cancelWorkManagerJobsLambda)
val presenter = createLogoutPresenter(workManagerScheduler = workManagerScheduler) val presenter = createLogoutPresenter(workManagerScheduler = workManagerScheduler)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -238,7 +239,7 @@ class LogoutPresenterTest {
internal fun createLogoutPresenter( internal fun createLogoutPresenter(
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
encryptionService: EncryptionService = FakeEncryptionService(), encryptionService: EncryptionService = FakeEncryptionService(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(cancelLambda = {}), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(cancelLambda = { _, _ -> }),
): LogoutPresenter = LogoutPresenter( ): LogoutPresenter = LogoutPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
encryptionService = encryptionService, encryptionService = encryptionService,

View file

@ -115,7 +115,7 @@ private fun ViolationAlert(
}, },
submitText = stringResource(submitTextId), submitText = stringResource(submitTextId),
onSubmitClick = onSubmitClick, onSubmitClick = onSubmitClick,
level = if (isCritical) ComposerAlertLevel.Critical else ComposerAlertLevel.Default, level = if (isCritical) ComposerAlertLevel.Critical else ComposerAlertLevel.Info,
) )
} }

View file

@ -30,5 +30,5 @@ data class TimelineItemStickerContent(
/* Stickers are supposed to be small images so /* Stickers are supposed to be small images so
we allow using the mediaSource (unless the url is empty) */ we allow using the mediaSource (unless the url is empty) */
val preferredMediaSource = if (mediaSource.url.isEmpty()) thumbnailSource else mediaSource val preferredMediaSource = if (mediaSource.safeUrl.isEmpty()) thumbnailSource else mediaSource
} }

View file

@ -31,6 +31,7 @@ dependencies {
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.workmanager.api)
testCommonDependencies(libs) testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)

View file

@ -0,0 +1,39 @@
/*
* 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.features.migration.impl.migrations
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
/**
* Remove existing fetch notifications work manager requests since their format has changed.
*/
@ContributesIntoSet(AppScope::class)
class AppMigration10(
private val sessionStore: SessionStore,
private val workManagerScheduler: WorkManagerScheduler,
) : AppMigration {
override val order: Int = 10
override suspend fun migrate(isFreshInstall: Boolean) {
if (isFreshInstall) return
val sessions = sessionStore.getAllSessions()
for (session in sessions) {
workManagerScheduler.cancel(
sessionId = SessionId(session.userId),
requestType = WorkManagerRequestType.NOTIFICATION_SYNC
)
}
}
}

View file

@ -0,0 +1,15 @@
/*
* 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.features.share.api
/**
* Post-processing to be done once a [ShareIntentData] has been consumed.
*/
fun interface OnSharedData {
operator fun invoke(data: ShareIntentData)
}

View file

@ -8,7 +8,6 @@
package io.element.android.features.share.api package io.element.android.features.share.api
import android.content.Intent
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
@ -16,7 +15,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
interface ShareEntryPoint : FeatureEntryPoint { interface ShareEntryPoint : FeatureEntryPoint {
data class Params(val intent: Intent) data class Params(val shareIntentData: ShareIntentData)
fun createNode( fun createNode(
parentNode: Node, parentNode: Node,

View file

@ -0,0 +1,38 @@
/*
* 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.features.share.api
import android.net.Uri
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Share intent data, mapped from the original [android.content.Intent].
*/
sealed interface ShareIntentData : Parcelable {
/**
* A list of [Uri]s to share and their mime types, with an optional [text] to be used as caption.
*/
@Parcelize
data class Uris(val text: String?, val uris: List<UriToShare>) : ShareIntentData
/**
* A plain text to share.
*/
@Parcelize
data class PlainText(val content: String) : ShareIntentData
}
/**
* A [Uri] coming from an external share intent, with its associated [mimeType].
*/
@Parcelize
data class UriToShare(
val uri: Uri,
val mimeType: String,
) : Parcelable

View file

@ -0,0 +1,21 @@
/*
* 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.features.share.api
import android.content.Intent
interface ShareIntentHandler {
/**
* This methods aims to handle incoming share intents and parse its data.
*
* @return the [ShareIntentData] if it could be resolved, or null.
*/
fun handleIncomingShareIntent(
intent: Intent
): ShareIntentData?
}

View file

@ -44,6 +44,7 @@ dependencies {
api(projects.features.share.api) api(projects.features.share.api)
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
testImplementation(projects.features.share.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)

View file

@ -0,0 +1,50 @@
/*
* 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.features.share.impl
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.share.api.OnSharedData
import io.element.android.features.share.api.ShareIntentData
import io.element.android.libraries.di.annotations.ApplicationContext
import timber.log.Timber
import kotlin.collections.forEach
@ContributesBinding(AppScope::class)
class DefaultOnSharedData(
@ApplicationContext private val context: Context,
) : OnSharedData {
override fun invoke(data: ShareIntentData) {
when (data) {
is ShareIntentData.PlainText -> {
// No-op, there is nothing to do for plain text intents.
}
is ShareIntentData.Uris -> {
revokeUriPermissions(data.uris.map { it.uri })
}
}
}
private fun revokeUriPermissions(uris: List<Uri>) {
uris.forEach { uri ->
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.revokeUriPermission(context.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
} else {
context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
} catch (e: Exception) {
Timber.w(e, "Unable to revoke Uri permission")
}
}
}
}

View file

@ -26,7 +26,7 @@ class DefaultShareEntryPoint : ShareEntryPoint {
return parentNode.createNode<ShareNode>( return parentNode.createNode<ShareNode>(
buildContext = buildContext, buildContext = buildContext,
plugins = listOf( plugins = listOf(
ShareNode.Inputs(intent = params.intent), ShareNode.Inputs(shareIntentData = params.shareIntentData),
callback, callback,
) )
) )

View file

@ -1,6 +1,5 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
@ -14,10 +13,12 @@ import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ResolveInfo import android.content.pm.ResolveInfo
import android.net.Uri import android.net.Uri
import android.os.Build
import androidx.core.content.IntentCompat import androidx.core.content.IntentCompat
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.share.api.ShareIntentData
import io.element.android.features.share.api.ShareIntentHandler
import io.element.android.features.share.api.UriToShare
import io.element.android.libraries.androidutils.compat.queryIntentActivitiesCompat import io.element.android.libraries.androidutils.compat.queryIntentActivitiesCompat
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAny import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAny
@ -30,37 +31,17 @@ import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
import timber.log.Timber import timber.log.Timber
interface ShareIntentHandler {
data class UriToShare(
val uri: Uri,
val mimeType: String,
)
/**
* This methods aims to handle incoming share intents.
*
* @return true if it can handle the intent data, false otherwise
*/
suspend fun handleIncomingShareIntent(
intent: Intent,
onUris: suspend (List<UriToShare>) -> Boolean,
onPlainText: suspend (String) -> Boolean,
): Boolean
}
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultShareIntentHandler( class DefaultShareIntentHandler(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) : ShareIntentHandler { ) : ShareIntentHandler {
override suspend fun handleIncomingShareIntent( override fun handleIncomingShareIntent(
intent: Intent, intent: Intent,
onUris: suspend (List<ShareIntentHandler.UriToShare>) -> Boolean, ): ShareIntentData? {
onPlainText: suspend (String) -> Boolean, val type = intent.resolveType(context) ?: return null
): Boolean {
val type = intent.resolveType(context) ?: return false
val uris = getIncomingUris(intent, type) val uris = getIncomingUris(intent, type)
return when { return when {
uris.isEmpty() && type == MimeTypes.PlainText -> handlePlainText(intent, onPlainText) uris.isEmpty() && type == MimeTypes.PlainText -> handlePlainText(intent)
type.isMimeTypeImage() || type.isMimeTypeImage() ||
type.isMimeTypeVideo() || type.isMimeTypeVideo() ||
type.isMimeTypeAudio() || type.isMimeTypeAudio() ||
@ -68,20 +49,21 @@ class DefaultShareIntentHandler(
type.isMimeTypeFile() || type.isMimeTypeFile() ||
type.isMimeTypeText() || type.isMimeTypeText() ||
type.isMimeTypeAny() -> { type.isMimeTypeAny() -> {
val result = onUris(uris) ShareIntentData.Uris(
revokeUriPermissions(uris.map { it.uri }) text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.takeIf { it.isNotEmpty() },
result uris = uris,
)
} }
else -> false else -> null
} }
} }
private suspend fun handlePlainText(intent: Intent, onPlainText: suspend (String) -> Boolean): Boolean { private fun handlePlainText(intent: Intent): ShareIntentData.PlainText? {
val content = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() val content = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()
return if (content?.isNotEmpty() == true) { return if (content?.isNotEmpty() == true) {
onPlainText(content) ShareIntentData.PlainText(content)
} else { } else {
false null
} }
} }
@ -89,7 +71,7 @@ class DefaultShareIntentHandler(
* Use this function to retrieve files which are shared from another application or internally * Use this function to retrieve files which are shared from another application or internally
* by using android.intent.action.SEND or android.intent.action.SEND_MULTIPLE actions. * by using android.intent.action.SEND or android.intent.action.SEND_MULTIPLE actions.
*/ */
private fun getIncomingUris(intent: Intent, fallbackMimeType: String): List<ShareIntentHandler.UriToShare> { private fun getIncomingUris(intent: Intent, fallbackMimeType: String): List<UriToShare> {
val uriList = mutableListOf<Uri>() val uriList = mutableListOf<Uri>()
if (intent.action == Intent.ACTION_SEND) { if (intent.action == Intent.ACTION_SEND) {
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
@ -118,24 +100,10 @@ class DefaultShareIntentHandler(
// The value in fallbackMimeType can be wrong, especially if several uris were received // The value in fallbackMimeType can be wrong, especially if several uris were received
// in the same intent (i.e. 'image/*'). We need to check the mime type of each uri. // in the same intent (i.e. 'image/*'). We need to check the mime type of each uri.
val mimeType = context.contentResolver.getType(uri) ?: fallbackMimeType val mimeType = context.contentResolver.getType(uri) ?: fallbackMimeType
ShareIntentHandler.UriToShare( UriToShare(
uri = uri, uri = uri,
mimeType = mimeType, mimeType = mimeType,
) )
} }
} }
private fun revokeUriPermissions(uris: List<Uri>) {
uris.forEach { uri ->
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.revokeUriPermission(context.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
} else {
context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
} catch (e: Exception) {
Timber.w(e, "Unable to revoke Uri permission")
}
}
}
} }

View file

@ -8,7 +8,6 @@
package io.element.android.features.share.impl package io.element.android.features.share.impl
import android.content.Intent
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -23,6 +22,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.share.api.ShareIntentData
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
@ -50,10 +50,10 @@ class ShareNode(
@Parcelize @Parcelize
object NavTarget : Parcelable object NavTarget : Parcelable
data class Inputs(val intent: Intent) : NodeInputs data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.intent) private val presenter = presenterFactory.create(inputs.shareIntentData)
private val callback: ShareEntryPoint.Callback = callback() private val callback: ShareEntryPoint.Callback = callback()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {

View file

@ -8,13 +8,14 @@
package io.element.android.features.share.impl package io.element.android.features.share.impl
import android.content.Intent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.features.share.api.OnSharedData
import io.element.android.features.share.api.ShareIntentData
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -32,24 +33,24 @@ import kotlin.coroutines.cancellation.CancellationException
@AssistedInject @AssistedInject
class SharePresenter( class SharePresenter(
@Assisted private val intent: Intent, @Assisted private val shareIntentData: ShareIntentData,
@SessionCoroutineScope @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
private val shareIntentHandler: ShareIntentHandler,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val mediaSenderRoomFactory: MediaSenderRoomFactory, private val mediaSenderRoomFactory: MediaSenderRoomFactory,
private val activeRoomsHolder: ActiveRoomsHolder, private val activeRoomsHolder: ActiveRoomsHolder,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val onSharedData: OnSharedData,
) : Presenter<ShareState> { ) : Presenter<ShareState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
fun create(intent: Intent): SharePresenter fun create(shareIntentData: ShareIntentData): SharePresenter
} }
private val shareActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized) private val shareActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
fun onRoomSelected(roomIds: List<RoomId>) { fun onRoomSelected(roomIds: List<RoomId>) {
sessionCoroutineScope.share(intent, roomIds) sessionCoroutineScope.share(shareIntentData, roomIds)
} }
@Composable @Composable
@ -73,13 +74,24 @@ class SharePresenter(
} }
private fun CoroutineScope.share( private fun CoroutineScope.share(
intent: Intent, shareIntentData: ShareIntentData,
roomIds: List<RoomId>, roomIds: List<RoomId>,
) = launch { ) = launch {
suspend { suspend {
val result = shareIntentHandler.handleIncomingShareIntent( val result = when (shareIntentData) {
intent, is ShareIntentData.PlainText -> {
onUris = { filesToShare -> roomIds
.map { roomId ->
getJoinedRoom(roomId)?.liveTimeline?.sendMessage(
body = shareIntentData.content,
htmlBody = null,
intentionalMentions = emptyList(),
)?.isSuccess.orFalse()
}
.all { it }
}
is ShareIntentData.Uris -> {
val filesToShare = shareIntentData.uris
if (filesToShare.isEmpty()) { if (filesToShare.isEmpty()) {
false false
} else { } else {
@ -90,6 +102,7 @@ class SharePresenter(
filesToShare filesToShare
.map { fileToShare -> .map { fileToShare ->
val result = mediaSender.sendMedia( val result = mediaSender.sendMedia(
caption = shareIntentData.text,
uri = fileToShare.uri, uri = fileToShare.uri,
mimeType = fileToShare.mimeType, mimeType = fileToShare.mimeType,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
@ -113,19 +126,12 @@ class SharePresenter(
} }
.all { it } .all { it }
} }
},
onPlainText = { text ->
roomIds
.map { roomId ->
getJoinedRoom(roomId)?.liveTimeline?.sendMessage(
body = text,
htmlBody = null,
intentionalMentions = emptyList(),
)?.isSuccess.orFalse()
}
.all { it }
} }
) }
// Handle post-processing of shared data
onSharedData(shareIntentData)
if (!result) { if (!result) {
error("Failed to handle incoming share intent") error("Failed to handle incoming share intent")
} }

View file

@ -8,13 +8,14 @@
package io.element.android.features.share.impl package io.element.android.features.share.impl
import android.content.Intent
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.share.api.ShareIntentData
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
@ -44,7 +45,7 @@ class DefaultShareEntryPointTest {
override fun onDone(roomIds: List<RoomId>) = lambdaError() override fun onDone(roomIds: List<RoomId>) = lambdaError()
} }
val params = ShareEntryPoint.Params( val params = ShareEntryPoint.Params(
intent = Intent(), shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
) )
val result = entryPoint.createNode( val result = entryPoint.createNode(
parentNode = parentNode, parentNode = parentNode,
@ -53,7 +54,7 @@ class DefaultShareEntryPointTest {
callback = callback, callback = callback,
) )
assertThat(result).isInstanceOf(ShareNode::class.java) assertThat(result).isInstanceOf(ShareNode::class.java)
assertThat(result.plugins).contains(ShareNode.Inputs(params.intent)) assertThat(result.plugins).contains(ShareNode.Inputs(params.shareIntentData))
assertThat(result.plugins).contains(callback) assertThat(result.plugins).contains(callback)
} }
} }

View file

@ -1,27 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.features.share.impl
import android.content.Intent
class FakeShareIntentHandler(
private val onIncomingShareIntent: suspend (
Intent,
suspend (List<ShareIntentHandler.UriToShare>) -> Boolean,
suspend (String) -> Boolean,
) -> Boolean = { _, _, _ -> false },
) : ShareIntentHandler {
override suspend fun handleIncomingShareIntent(
intent: Intent,
onUris: suspend (List<ShareIntentHandler.UriToShare>) -> Boolean,
onPlainText: suspend (String) -> Boolean,
): Boolean {
return onIncomingShareIntent(intent, onUris, onPlainText)
}
}

View file

@ -8,12 +8,14 @@
package io.element.android.features.share.impl package io.element.android.features.share.impl
import android.content.Intent
import android.net.Uri import android.net.Uri
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.share.api.OnSharedData
import io.element.android.features.share.api.ShareIntentData
import io.element.android.features.share.api.UriToShare
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@ -72,8 +74,17 @@ class SharePresenterTest {
@Test @Test
fun `present - on room selected ok`() = runTest { fun `present - on room selected ok`() = runTest {
val joinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendMessageLambda = { _, _, _ -> Result.success(Unit) }
},
)
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, joinedRoom)
}
val presenter = createSharePresenter( val presenter = createSharePresenter(
shareIntentHandler = FakeShareIntentHandler { _, _, _ -> true } matrixClient = matrixClient,
shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -100,9 +111,7 @@ class SharePresenterTest {
} }
val presenter = createSharePresenter( val presenter = createSharePresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
shareIntentHandler = FakeShareIntentHandler { _, _, onText -> shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
onText(A_MESSAGE)
}
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -131,16 +140,15 @@ class SharePresenterTest {
) )
val presenter = createSharePresenter( val presenter = createSharePresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
shareIntentHandler = FakeShareIntentHandler { _, onFile, _ -> shareIntentData = ShareIntentData.Uris(
onFile( text = A_MESSAGE,
listOf( listOf(
ShareIntentHandler.UriToShare( UriToShare(
uri = Uri.parse("content://image.jpg"), uri = Uri.parse("content://image.jpg"),
mimeType = MimeTypes.Jpeg, mimeType = MimeTypes.Jpeg,
)
) )
) )
}, ),
mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender }, mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender },
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -159,20 +167,20 @@ class SharePresenterTest {
} }
internal fun TestScope.createSharePresenter( internal fun TestScope.createSharePresenter(
intent: Intent = Intent(), shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE),
shareIntentHandler: ShareIntentHandler = FakeShareIntentHandler(),
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(),
mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() }, mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() },
mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
onSharedData: OnSharedData = OnSharedData {},
): SharePresenter { ): SharePresenter {
return SharePresenter( return SharePresenter(
intent = intent, shareIntentData = shareIntentData,
sessionCoroutineScope = this, sessionCoroutineScope = this,
shareIntentHandler = shareIntentHandler,
matrixClient = matrixClient, matrixClient = matrixClient,
activeRoomsHolder = activeRoomsHolder, activeRoomsHolder = activeRoomsHolder,
mediaSenderRoomFactory = mediaSenderRoomFactory, mediaSenderRoomFactory = mediaSenderRoomFactory,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
onSharedData = onSharedData,
) )
} }

View file

@ -0,0 +1,21 @@
/*
* 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.share.test"
}
dependencies {
implementation(projects.features.share.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)
}

View file

@ -0,0 +1,22 @@
/*
* 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.features.share.test
import android.content.Intent
import io.element.android.features.share.api.ShareIntentData
import io.element.android.features.share.api.ShareIntentHandler
class FakeShareIntentHandler(
private val onIncomingShareIntent: (Intent) -> ShareIntentData? = { null },
) : ShareIntentHandler {
override fun handleIncomingShareIntent(
intent: Intent,
): ShareIntentData? {
return onIncomingShareIntent(intent)
}
}

View file

@ -51,10 +51,10 @@ telephoto = "0.18.0"
haze = "1.7.2" haze = "1.7.2"
# Dependency analysis # Dependency analysis
dependencyAnalysis = "3.6.0" dependencyAnalysis = "3.6.1"
# DI # DI
metro = "0.11.1" metro = "0.11.2"
# Auto service # Auto service
autoservice = "1.1.1" autoservice = "1.1.1"

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fa16f659aa3e7d05fa03a51d52faddc0c40c3ab52231687f8c6c8a4ba81ff6f0 oid sha256:460ddd253f4029b29edde9d858237204acb55aca7e13e92bc691ea71ca34c53e
size 219813 size 237462

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:72fb457dc50bf1a2261502fc1da15c01ab415344e9070354d38dc7b74234d790 oid sha256:a0b76eee73be6a2ba58eb12883477ebce7daf039b6c60637e263a478a0bc68fe
size 232095 size 250668

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:24cfe760717881ee71f36fae1fb201e74b2c32a2f9a5aef71ef21dab69ea5366 oid sha256:d12abdfa2a2d7d1943e0e377279134b44de0ad5d8fb12b09e23c2083b728989f
size 233212 size 251951

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c846cd10b83361c368bdbb31ed6220cc22693c3cbf52791fb369841af1e9ea48 oid sha256:afb0295b04f302c25f40774562e7d5b2bb668c4cf1158b521ae9b50a35a58d2b
size 327701 size 322068

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:05b35fedbd53dec2cc5c4c211a8db1a56055963de69425ddae2cab5aff7e3e75 oid sha256:3b94a6d004999869b8650559a70a1427882408b242c9b47788e56320aaeef34c
size 325750 size 320114

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8d98e64eda5d6333067ccc599e99636f618331397207bb7534595e2756edb75e oid sha256:06cebaaf9e0e4f2b69231ab2b866652419e70df50b0abb68288e08f748ed9b76
size 309312 size 301985

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5ccbf1234065b182939f001eb65eca0a62adae41a2d91ef0307d27b059407178 oid sha256:baf841165dfd7c6315dc7bd82d1be8935976d0a9a70e83f4d70e23a2389dab95
size 309084 size 301760

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a699170cabca6fb912d034a588b45961485afe6ef6d2c24f0ab79f10ae00c168 oid sha256:40f0940bd8a5ddee96ea2aac01d9672478fc15044621bfb10f5f0b20d61f035d
size 85629 size 93402

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dd4b2a40fcf02d6db29cb0bc371d93236b4a0be6d4446bab86358692cddb53f5 oid sha256:48d8c1bef4a59554649fab33aa716ca2e9fe24f29a6b7e0dae9c404afedd6695
size 91692 size 99735

File diff suppressed because one or more lines are too long

View file

@ -155,12 +155,10 @@ private fun getSemanticColors(): ImmutableMap<String, Color> {
"gradientActionStop2" to gradientActionStop2, "gradientActionStop2" to gradientActionStop2,
"gradientActionStop3" to gradientActionStop3, "gradientActionStop3" to gradientActionStop3,
"gradientActionStop4" to gradientActionStop4, "gradientActionStop4" to gradientActionStop4,
"gradientCriticalStop1" to gradientCriticalStop1,
"gradientCriticalStop2" to gradientCriticalStop2,
"gradientInfoStop1" to gradientInfoStop1, "gradientInfoStop1" to gradientInfoStop1,
"gradientInfoStop2" to gradientInfoStop2, "gradientInfoStop2" to gradientInfoStop2,
"gradientInfoStop3" to gradientInfoStop3,
"gradientInfoStop4" to gradientInfoStop4,
"gradientInfoStop5" to gradientInfoStop5,
"gradientInfoStop6" to gradientInfoStop6,
"gradientSubtleStop1" to gradientSubtleStop1, "gradientSubtleStop1" to gradientSubtleStop1,
"gradientSubtleStop2" to gradientSubtleStop2, "gradientSubtleStop2" to gradientSubtleStop2,
"gradientSubtleStop3" to gradientSubtleStop3, "gradientSubtleStop3" to gradientSubtleStop3,

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
@ -25,6 +25,9 @@ object CompoundIcons {
@Composable fun Admin(): ImageVector { @Composable fun Admin(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_admin) return ImageVector.vectorResource(R.drawable.ic_compound_admin)
} }
@Composable fun AdvancedSettings(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_advanced_settings)
}
@Composable fun ArrowDown(): ImageVector { @Composable fun ArrowDown(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_arrow_down) return ImageVector.vectorResource(R.drawable.ic_compound_arrow_down)
} }
@ -64,6 +67,9 @@ object CompoundIcons {
@Composable fun Bold(): ImageVector { @Composable fun Bold(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_bold) return ImageVector.vectorResource(R.drawable.ic_compound_bold)
} }
@Composable fun Bug(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_bug)
}
@Composable fun Calendar(): ImageVector { @Composable fun Calendar(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_calendar) return ImageVector.vectorResource(R.drawable.ic_compound_calendar)
} }
@ -460,6 +466,9 @@ object CompoundIcons {
@Composable fun RaisedHandSolid(): ImageVector { @Composable fun RaisedHandSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_raised_hand_solid) return ImageVector.vectorResource(R.drawable.ic_compound_raised_hand_solid)
} }
@Composable fun ReOrder(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_re_order)
}
@Composable fun Reaction(): ImageVector { @Composable fun Reaction(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_reaction) return ImageVector.vectorResource(R.drawable.ic_compound_reaction)
} }
@ -478,9 +487,18 @@ object CompoundIcons {
@Composable fun Room(): ImageVector { @Composable fun Room(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_room) return ImageVector.vectorResource(R.drawable.ic_compound_room)
} }
@Composable fun RotateLeft(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_rotate_left)
}
@Composable fun RotateRight(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_rotate_right)
}
@Composable fun Search(): ImageVector { @Composable fun Search(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_search) return ImageVector.vectorResource(R.drawable.ic_compound_search)
} }
@Composable fun Section(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_section)
}
@Composable fun Send(): ImageVector { @Composable fun Send(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_send) return ImageVector.vectorResource(R.drawable.ic_compound_send)
} }
@ -535,6 +553,12 @@ object CompoundIcons {
@Composable fun Sticker(): ImageVector { @Composable fun Sticker(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_sticker) return ImageVector.vectorResource(R.drawable.ic_compound_sticker)
} }
@Composable fun Stop(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_stop)
}
@Composable fun StopSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_stop_solid)
}
@Composable fun Strikethrough(): ImageVector { @Composable fun Strikethrough(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_strikethrough) return ImageVector.vectorResource(R.drawable.ic_compound_strikethrough)
} }
@ -550,6 +574,9 @@ object CompoundIcons {
@Composable fun TextFormatting(): ImageVector { @Composable fun TextFormatting(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_text_formatting) return ImageVector.vectorResource(R.drawable.ic_compound_text_formatting)
} }
@Composable fun Theme(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_theme)
}
@Composable fun Threads(): ImageVector { @Composable fun Threads(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_threads) return ImageVector.vectorResource(R.drawable.ic_compound_threads)
} }
@ -559,6 +586,12 @@ object CompoundIcons {
@Composable fun Time(): ImageVector { @Composable fun Time(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_time) return ImageVector.vectorResource(R.drawable.ic_compound_time)
} }
@Composable fun Translate(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_translate)
}
@Composable fun Tree(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_tree)
}
@Composable fun Underline(): ImageVector { @Composable fun Underline(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_underline) return ImageVector.vectorResource(R.drawable.ic_compound_underline)
} }
@ -607,6 +640,9 @@ object CompoundIcons {
@Composable fun VideoCallOffSolid(): ImageVector { @Composable fun VideoCallOffSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_video_call_off_solid) return ImageVector.vectorResource(R.drawable.ic_compound_video_call_off_solid)
} }
@Composable fun VideoCallOutgoingSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_video_call_outgoing_solid)
}
@Composable fun VideoCallSolid(): ImageVector { @Composable fun VideoCallSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_video_call_solid) return ImageVector.vectorResource(R.drawable.ic_compound_video_call_solid)
} }
@ -619,6 +655,15 @@ object CompoundIcons {
@Composable fun VoiceCall(): ImageVector { @Composable fun VoiceCall(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_voice_call) return ImageVector.vectorResource(R.drawable.ic_compound_voice_call)
} }
@Composable fun VoiceCallDeclinedSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_declined_solid)
}
@Composable fun VoiceCallMissedSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_missed_solid)
}
@Composable fun VoiceCallOutgoingSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_outgoing_solid)
}
@Composable fun VoiceCallSolid(): ImageVector { @Composable fun VoiceCallSolid(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_solid) return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_solid)
} }
@ -643,9 +688,16 @@ object CompoundIcons {
@Composable fun Windows(): ImageVector { @Composable fun Windows(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_windows) return ImageVector.vectorResource(R.drawable.ic_compound_windows)
} }
@Composable fun ZoomIn(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_zoom_in)
}
@Composable fun ZoomOut(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_zoom_out)
}
val all @Composable get() = persistentListOf<ImageVector>( val all @Composable get() = persistentListOf<ImageVector>(
Admin(), Admin(),
AdvancedSettings(),
ArrowDown(), ArrowDown(),
ArrowLeft(), ArrowLeft(),
ArrowRight(), ArrowRight(),
@ -659,6 +711,7 @@ object CompoundIcons {
BackspaceSolid(), BackspaceSolid(),
Block(), Block(),
Bold(), Bold(),
Bug(),
Calendar(), Calendar(),
Chart(), Chart(),
Chat(), Chat(),
@ -791,13 +844,17 @@ object CompoundIcons {
QrCode(), QrCode(),
Quote(), Quote(),
RaisedHandSolid(), RaisedHandSolid(),
ReOrder(),
Reaction(), Reaction(),
ReactionAdd(), ReactionAdd(),
ReactionSolid(), ReactionSolid(),
Reply(), Reply(),
Restart(), Restart(),
Room(), Room(),
RotateLeft(),
RotateRight(),
Search(), Search(),
Section(),
Send(), Send(),
SendSolid(), SendSolid(),
Settings(), Settings(),
@ -816,14 +873,19 @@ object CompoundIcons {
Spotlight(), Spotlight(),
SpotlightView(), SpotlightView(),
Sticker(), Sticker(),
Stop(),
StopSolid(),
Strikethrough(), Strikethrough(),
SwitchCameraSolid(), SwitchCameraSolid(),
TakePhoto(), TakePhoto(),
TakePhotoSolid(), TakePhotoSolid(),
TextFormatting(), TextFormatting(),
Theme(),
Threads(), Threads(),
ThreadsSolid(), ThreadsSolid(),
Time(), Time(),
Translate(),
Tree(),
Underline(), Underline(),
Unknown(), Unknown(),
UnknownSolid(), UnknownSolid(),
@ -840,10 +902,14 @@ object CompoundIcons {
VideoCallMissedSolid(), VideoCallMissedSolid(),
VideoCallOff(), VideoCallOff(),
VideoCallOffSolid(), VideoCallOffSolid(),
VideoCallOutgoingSolid(),
VideoCallSolid(), VideoCallSolid(),
VisibilityOff(), VisibilityOff(),
VisibilityOn(), VisibilityOn(),
VoiceCall(), VoiceCall(),
VoiceCallDeclinedSolid(),
VoiceCallMissedSolid(),
VoiceCallOutgoingSolid(),
VoiceCallSolid(), VoiceCallSolid(),
VolumeOff(), VolumeOff(),
VolumeOffSolid(), VolumeOffSolid(),
@ -852,10 +918,13 @@ object CompoundIcons {
Warning(), Warning(),
WebBrowser(), WebBrowser(),
Windows(), Windows(),
ZoomIn(),
ZoomOut(),
) )
val allResIds get() = persistentListOf( val allResIds get() = persistentListOf(
R.drawable.ic_compound_admin, R.drawable.ic_compound_admin,
R.drawable.ic_compound_advanced_settings,
R.drawable.ic_compound_arrow_down, R.drawable.ic_compound_arrow_down,
R.drawable.ic_compound_arrow_left, R.drawable.ic_compound_arrow_left,
R.drawable.ic_compound_arrow_right, R.drawable.ic_compound_arrow_right,
@ -869,6 +938,7 @@ object CompoundIcons {
R.drawable.ic_compound_backspace_solid, R.drawable.ic_compound_backspace_solid,
R.drawable.ic_compound_block, R.drawable.ic_compound_block,
R.drawable.ic_compound_bold, R.drawable.ic_compound_bold,
R.drawable.ic_compound_bug,
R.drawable.ic_compound_calendar, R.drawable.ic_compound_calendar,
R.drawable.ic_compound_chart, R.drawable.ic_compound_chart,
R.drawable.ic_compound_chat, R.drawable.ic_compound_chat,
@ -1001,13 +1071,17 @@ object CompoundIcons {
R.drawable.ic_compound_qr_code, R.drawable.ic_compound_qr_code,
R.drawable.ic_compound_quote, R.drawable.ic_compound_quote,
R.drawable.ic_compound_raised_hand_solid, R.drawable.ic_compound_raised_hand_solid,
R.drawable.ic_compound_re_order,
R.drawable.ic_compound_reaction, R.drawable.ic_compound_reaction,
R.drawable.ic_compound_reaction_add, R.drawable.ic_compound_reaction_add,
R.drawable.ic_compound_reaction_solid, R.drawable.ic_compound_reaction_solid,
R.drawable.ic_compound_reply, R.drawable.ic_compound_reply,
R.drawable.ic_compound_restart, R.drawable.ic_compound_restart,
R.drawable.ic_compound_room, R.drawable.ic_compound_room,
R.drawable.ic_compound_rotate_left,
R.drawable.ic_compound_rotate_right,
R.drawable.ic_compound_search, R.drawable.ic_compound_search,
R.drawable.ic_compound_section,
R.drawable.ic_compound_send, R.drawable.ic_compound_send,
R.drawable.ic_compound_send_solid, R.drawable.ic_compound_send_solid,
R.drawable.ic_compound_settings, R.drawable.ic_compound_settings,
@ -1026,14 +1100,19 @@ object CompoundIcons {
R.drawable.ic_compound_spotlight, R.drawable.ic_compound_spotlight,
R.drawable.ic_compound_spotlight_view, R.drawable.ic_compound_spotlight_view,
R.drawable.ic_compound_sticker, R.drawable.ic_compound_sticker,
R.drawable.ic_compound_stop,
R.drawable.ic_compound_stop_solid,
R.drawable.ic_compound_strikethrough, R.drawable.ic_compound_strikethrough,
R.drawable.ic_compound_switch_camera_solid, R.drawable.ic_compound_switch_camera_solid,
R.drawable.ic_compound_take_photo, R.drawable.ic_compound_take_photo,
R.drawable.ic_compound_take_photo_solid, R.drawable.ic_compound_take_photo_solid,
R.drawable.ic_compound_text_formatting, R.drawable.ic_compound_text_formatting,
R.drawable.ic_compound_theme,
R.drawable.ic_compound_threads, R.drawable.ic_compound_threads,
R.drawable.ic_compound_threads_solid, R.drawable.ic_compound_threads_solid,
R.drawable.ic_compound_time, R.drawable.ic_compound_time,
R.drawable.ic_compound_translate,
R.drawable.ic_compound_tree,
R.drawable.ic_compound_underline, R.drawable.ic_compound_underline,
R.drawable.ic_compound_unknown, R.drawable.ic_compound_unknown,
R.drawable.ic_compound_unknown_solid, R.drawable.ic_compound_unknown_solid,
@ -1050,10 +1129,14 @@ object CompoundIcons {
R.drawable.ic_compound_video_call_missed_solid, R.drawable.ic_compound_video_call_missed_solid,
R.drawable.ic_compound_video_call_off, R.drawable.ic_compound_video_call_off,
R.drawable.ic_compound_video_call_off_solid, R.drawable.ic_compound_video_call_off_solid,
R.drawable.ic_compound_video_call_outgoing_solid,
R.drawable.ic_compound_video_call_solid, R.drawable.ic_compound_video_call_solid,
R.drawable.ic_compound_visibility_off, R.drawable.ic_compound_visibility_off,
R.drawable.ic_compound_visibility_on, R.drawable.ic_compound_visibility_on,
R.drawable.ic_compound_voice_call, R.drawable.ic_compound_voice_call,
R.drawable.ic_compound_voice_call_declined_solid,
R.drawable.ic_compound_voice_call_missed_solid,
R.drawable.ic_compound_voice_call_outgoing_solid,
R.drawable.ic_compound_voice_call_solid, R.drawable.ic_compound_voice_call_solid,
R.drawable.ic_compound_volume_off, R.drawable.ic_compound_volume_off,
R.drawable.ic_compound_volume_off_solid, R.drawable.ic_compound_volume_off_solid,
@ -1062,5 +1145,7 @@ object CompoundIcons {
R.drawable.ic_compound_warning, R.drawable.ic_compound_warning,
R.drawable.ic_compound_web_browser, R.drawable.ic_compound_web_browser,
R.drawable.ic_compound_windows, R.drawable.ic_compound_windows,
R.drawable.ic_compound_zoom_in,
R.drawable.ic_compound_zoom_out,
) )
} }

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
@ -121,18 +121,14 @@ data class SemanticColors(
val gradientActionStop3: Color, val gradientActionStop3: Color,
/** Background gradient stop for super and send buttons */ /** Background gradient stop for super and send buttons */
val gradientActionStop4: Color, val gradientActionStop4: Color,
/** Subtle background gradient stop for critical */
val gradientCriticalStop1: Color,
/** Subtle background gradient stop for critical */
val gradientCriticalStop2: Color,
/** Subtle background gradient stop for info */ /** Subtle background gradient stop for info */
val gradientInfoStop1: Color, val gradientInfoStop1: Color,
/** Subtle background gradient stop for info */ /** Subtle background gradient stop for info */
val gradientInfoStop2: Color, val gradientInfoStop2: Color,
/** Subtle background gradient stop for info */
val gradientInfoStop3: Color,
/** Subtle background gradient stop for info */
val gradientInfoStop4: Color,
/** Subtle background gradient stop for info */
val gradientInfoStop5: Color,
/** Subtle background gradient stop for info */
val gradientInfoStop6: Color,
/** Subtle background gradient stop for message highlight and bloom */ /** Subtle background gradient stop for message highlight and bloom */
val gradientSubtleStop1: Color, val gradientSubtleStop1: Color,
/** Subtle background gradient stop for message highlight and bloom */ /** Subtle background gradient stop for message highlight and bloom */

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
@ -73,12 +73,10 @@ val compoundColorsDark = SemanticColors(
gradientActionStop2 = DarkColorTokens.colorGreen900, gradientActionStop2 = DarkColorTokens.colorGreen900,
gradientActionStop3 = DarkColorTokens.colorGreen700, gradientActionStop3 = DarkColorTokens.colorGreen700,
gradientActionStop4 = DarkColorTokens.colorGreen500, gradientActionStop4 = DarkColorTokens.colorGreen500,
gradientInfoStop1 = DarkColorTokens.colorAlphaBlue500, gradientCriticalStop1 = DarkColorTokens.colorRed200,
gradientInfoStop2 = DarkColorTokens.colorAlphaBlue400, gradientCriticalStop2 = DarkColorTokens.colorThemeBg,
gradientInfoStop3 = DarkColorTokens.colorAlphaBlue300, gradientInfoStop1 = DarkColorTokens.colorBlue200,
gradientInfoStop4 = DarkColorTokens.colorAlphaBlue200, gradientInfoStop2 = DarkColorTokens.colorThemeBg,
gradientInfoStop5 = DarkColorTokens.colorAlphaBlue100,
gradientInfoStop6 = DarkColorTokens.colorTransparent,
gradientSubtleStop1 = DarkColorTokens.colorAlphaGreen500, gradientSubtleStop1 = DarkColorTokens.colorAlphaGreen500,
gradientSubtleStop2 = DarkColorTokens.colorAlphaGreen400, gradientSubtleStop2 = DarkColorTokens.colorAlphaGreen400,
gradientSubtleStop3 = DarkColorTokens.colorAlphaGreen300, gradientSubtleStop3 = DarkColorTokens.colorAlphaGreen300,

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
@ -73,12 +73,10 @@ val compoundColorsHcDark = SemanticColors(
gradientActionStop2 = DarkHcColorTokens.colorGreen900, gradientActionStop2 = DarkHcColorTokens.colorGreen900,
gradientActionStop3 = DarkHcColorTokens.colorGreen700, gradientActionStop3 = DarkHcColorTokens.colorGreen700,
gradientActionStop4 = DarkHcColorTokens.colorGreen500, gradientActionStop4 = DarkHcColorTokens.colorGreen500,
gradientInfoStop1 = DarkHcColorTokens.colorAlphaBlue500, gradientCriticalStop1 = DarkHcColorTokens.colorRed200,
gradientInfoStop2 = DarkHcColorTokens.colorAlphaBlue400, gradientCriticalStop2 = DarkHcColorTokens.colorThemeBg,
gradientInfoStop3 = DarkHcColorTokens.colorAlphaBlue300, gradientInfoStop1 = DarkHcColorTokens.colorBlue200,
gradientInfoStop4 = DarkHcColorTokens.colorAlphaBlue200, gradientInfoStop2 = DarkHcColorTokens.colorThemeBg,
gradientInfoStop5 = DarkHcColorTokens.colorAlphaBlue100,
gradientInfoStop6 = DarkHcColorTokens.colorTransparent,
gradientSubtleStop1 = DarkHcColorTokens.colorAlphaGreen500, gradientSubtleStop1 = DarkHcColorTokens.colorAlphaGreen500,
gradientSubtleStop2 = DarkHcColorTokens.colorAlphaGreen400, gradientSubtleStop2 = DarkHcColorTokens.colorAlphaGreen400,
gradientSubtleStop3 = DarkHcColorTokens.colorAlphaGreen300, gradientSubtleStop3 = DarkHcColorTokens.colorAlphaGreen300,

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
@ -73,12 +73,10 @@ val compoundColorsLight = SemanticColors(
gradientActionStop2 = LightColorTokens.colorGreen700, gradientActionStop2 = LightColorTokens.colorGreen700,
gradientActionStop3 = LightColorTokens.colorGreen900, gradientActionStop3 = LightColorTokens.colorGreen900,
gradientActionStop4 = LightColorTokens.colorGreen1100, gradientActionStop4 = LightColorTokens.colorGreen1100,
gradientInfoStop1 = LightColorTokens.colorAlphaBlue500, gradientCriticalStop1 = LightColorTokens.colorRed200,
gradientInfoStop2 = LightColorTokens.colorAlphaBlue400, gradientCriticalStop2 = LightColorTokens.colorThemeBg,
gradientInfoStop3 = LightColorTokens.colorAlphaBlue300, gradientInfoStop1 = LightColorTokens.colorBlue200,
gradientInfoStop4 = LightColorTokens.colorAlphaBlue200, gradientInfoStop2 = LightColorTokens.colorThemeBg,
gradientInfoStop5 = LightColorTokens.colorAlphaBlue100,
gradientInfoStop6 = LightColorTokens.colorTransparent,
gradientSubtleStop1 = LightColorTokens.colorAlphaGreen500, gradientSubtleStop1 = LightColorTokens.colorAlphaGreen500,
gradientSubtleStop2 = LightColorTokens.colorAlphaGreen400, gradientSubtleStop2 = LightColorTokens.colorAlphaGreen400,
gradientSubtleStop3 = LightColorTokens.colorAlphaGreen300, gradientSubtleStop3 = LightColorTokens.colorAlphaGreen300,

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
@ -73,12 +73,10 @@ val compoundColorsHcLight = SemanticColors(
gradientActionStop2 = LightHcColorTokens.colorGreen700, gradientActionStop2 = LightHcColorTokens.colorGreen700,
gradientActionStop3 = LightHcColorTokens.colorGreen900, gradientActionStop3 = LightHcColorTokens.colorGreen900,
gradientActionStop4 = LightHcColorTokens.colorGreen1100, gradientActionStop4 = LightHcColorTokens.colorGreen1100,
gradientInfoStop1 = LightHcColorTokens.colorAlphaBlue500, gradientCriticalStop1 = LightHcColorTokens.colorRed200,
gradientInfoStop2 = LightHcColorTokens.colorAlphaBlue400, gradientCriticalStop2 = LightHcColorTokens.colorThemeBg,
gradientInfoStop3 = LightHcColorTokens.colorAlphaBlue300, gradientInfoStop1 = LightHcColorTokens.colorBlue200,
gradientInfoStop4 = LightHcColorTokens.colorAlphaBlue200, gradientInfoStop2 = LightHcColorTokens.colorThemeBg,
gradientInfoStop5 = LightHcColorTokens.colorAlphaBlue100,
gradientInfoStop6 = LightHcColorTokens.colorTransparent,
gradientSubtleStop1 = LightHcColorTokens.colorAlphaGreen500, gradientSubtleStop1 = LightHcColorTokens.colorAlphaGreen500,
gradientSubtleStop2 = LightHcColorTokens.colorAlphaGreen400, gradientSubtleStop2 = LightHcColorTokens.colorAlphaGreen400,
gradientSubtleStop3 = LightHcColorTokens.colorAlphaGreen300, gradientSubtleStop3 = LightHcColorTokens.colorAlphaGreen300,

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.

View file

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2m0,18c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
<path
android:pathData="M13.49,11.38c0.43,-1.22 0.17,-2.64 -0.81,-3.62a3.47,3.47 0,0 0,-4.1 -0.59l2.35,2.35 -1.41,1.41 -2.35,-2.35c-0.71,1.32 -0.52,2.99 0.59,4.1 0.98,0.98 2.4,1.24 3.62,0.81l3.41,3.41c0.2,0.2 0.51,0.2 0.71,0l1.4,-1.4c0.2,-0.2 0.2,-0.51 0,-0.71z"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -4,11 +4,11 @@
android:autoMirrored="true" android:autoMirrored="true"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<group> <path
<clip-path android:pathData="M15.043,8.457a1,1 0,0 1,1.414 1.414l-2.043,2.043 2.129,2.129a1,1 0,1 1,-1.414 1.414l-2.13,-2.129 -2.127,2.129a1,1 0,0 1,-1.415 -1.414l2.129,-2.129 -2.043,-2.043a1,1 0,0 1,1.414 -1.414L13,10.5z"
android:pathData="M0,0h24v24H0z"/> android:fillColor="#FF000000"/>
<path <path
android:pathData="M21.167,3.75L7.417,3.75c-0.633,0 -1.128,0.32 -1.458,0.807L1,12l4.96,7.434c0.33,0.486 0.824,0.816 1.457,0.816h13.75A1.84,1.84 0,0 0,23 18.417L23,5.583a1.84,1.84 0,0 0,-1.833 -1.833m0,14.667L7.48,18.417L3.2,12l4.272,-6.417h13.695zM10.542,16.583 L13.833,13.293 17.124,16.583 18.417,15.291L15.126,12l3.29,-3.29 -1.292,-1.293 -3.29,3.29 -3.291,-3.29L9.25,8.709 12.54,12l-3.29,3.29z" android:pathData="M20,4a2,2 0,0 1,2 2v12a2,2 0,0 1,-2 2H7.28a2,2 0,0 1,-1.655 -0.877l-4.072,-6a2,2 0,0 1,0 -2.246l4.072,-6A2,2 0,0 1,7.28 4zM3.208,12l4.072,6H20V6H7.28z"
android:fillColor="#FF000000"/> android:fillColor="#FF000000"
</group> android:fillType="evenOdd"/>
</vector> </vector>

View file

@ -5,6 +5,7 @@
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path
android:pathData="M21.15,4H7.283c-0.638,0 -1.137,0.311 -1.47,0.782l-4.66,6.73a0.87,0.87 0,0 0,0 0.986l4.66,6.72c0.333,0.462 0.832,0.782 1.47,0.782h13.869C22.168,20 23,19.2 23,18.222V5.778C23,4.8 22.168,4 21.15,4m-3.42,11.822a0.947,0.947 0,0 1,-1.304 0l-2.672,-2.569 -2.672,2.57a0.947,0.947 0,0 1,-1.303 0,0.86 0.86,0 0,1 0,-1.254L12.45,12 9.779,9.431a0.86,0.86 0,0 1,0 -1.253,0.947 0.947,0 0,1 1.303,0l2.672,2.569 2.672,-2.57a0.947,0.947 0,0 1,1.304 0c0.36,0.347 0.36,0.907 0,1.254L15.058,12l2.672,2.569a0.877,0.877 0,0 1,0 1.253" android:pathData="M20,4a2,2 0,0 1,2 2v12a2,2 0,0 1,-2 2L7.33,20a2,2 0,0 1,-1.673 -0.902l-3.937,-6a2,2 0,0 1,0 -2.196l3.937,-6A2,2 0,0 1,7.33 4zM16.457,8.457a1,1 0,0 0,-1.414 0L13,10.5l-2.043,-2.043a1,1 0,0 0,-1.414 1.414l2.043,2.043 -2.129,2.129a1,1 0,0 0,1.414 1.414l2.13,-2.129 2.128,2.129a1,1 0,0 0,1.414 -1.414l-2.129,-2.129 2.043,-2.043a1,1 0,0 0,0 -1.414"
android:fillColor="#FF000000"/> android:fillColor="#FF000000"
android:fillType="evenOdd"/>
</vector> </vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19,8h-1.81a6,6 0,0 0,-1.82 -1.96l0.93,-0.93a0.996,0.996 0,1 0,-1.41 -1.41l-1.47,1.47C12.96,5.06 12.49,5 12,5s-0.96,0.06 -1.41,0.17L9.11,3.7A0.996,0.996 0,1 0,7.7 5.11l0.92,0.93C7.88,6.55 7.26,7.22 6.81,8H5c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h1.09c-0.05,0.33 -0.09,0.66 -0.09,1v1H5c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h1v1c0,0.34 0.04,0.67 0.09,1H5c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h1.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3H19c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1h-1.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h1c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1h-1v-1c0,-0.34 -0.04,-0.67 -0.09,-1H19c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1m-6,8h-2c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h2c0.55,0 1,0.45 1,1s-0.45,1 -1,1m0,-4h-2c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h2c0.55,0 1,0.45 1,1s-0.45,1 -1,1"
android:fillColor="#FF000000"/>
</vector>

View file

@ -4,7 +4,7 @@
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path
android:pathData="M16.099,2.4a4.1,4.1 0,0 1,-1.057 3.074c-0.747,0.862 -1.878,1.358 -3.07,1.347 -0.075,-1.081 0.315,-2.146 1.085,-2.96 0.78,-0.825 1.866,-1.346 3.042,-1.461m3.767,6.54c-1.37,0.783 -2.213,2.163 -2.234,3.657 0.002,1.69 1.092,3.215 2.768,3.873a9.4,9.4 0,0 1,-1.44 2.723c-0.848,1.178 -1.737,2.329 -3.149,2.35 -0.67,0.015 -1.124,-0.165 -1.596,-0.351 -0.493,-0.195 -1.006,-0.398 -1.809,-0.398 -0.851,0 -1.388,0.21 -1.905,0.412 -0.447,0.174 -0.88,0.343 -1.49,0.367 -1.343,0.046 -2.37,-1.258 -3.25,-2.425 -1.756,-2.383 -3.124,-6.716 -1.29,-9.664 0.861,-1.437 2.471,-2.349 4.241,-2.402 0.763,-0.015 1.494,0.258 2.136,0.497 0.49,0.183 0.928,0.347 1.286,0.347 0.315,0 0.74,-0.157 1.237,-0.34 0.78,-0.288 1.737,-0.64 2.71,-0.545 1.514,0.044 2.917,0.748 3.785,1.9" android:pathData="M16.099,2.4a4.1,4.1 0,0 1,-1.057 3.073c-0.747,0.863 -1.878,1.36 -3.07,1.348 -0.075,-1.081 0.315,-2.146 1.085,-2.96 0.78,-0.825 1.866,-1.346 3.042,-1.461m3.767,6.54c-1.37,0.783 -2.214,2.163 -2.234,3.657 0.002,1.69 1.092,3.215 2.768,3.873a9.4,9.4 0,0 1,-1.44 2.723c-0.848,1.178 -1.737,2.329 -3.149,2.35 -0.671,0.015 -1.124,-0.165 -1.596,-0.351 -0.493,-0.195 -1.006,-0.398 -1.809,-0.398 -0.852,0 -1.388,0.21 -1.905,0.412 -0.447,0.174 -0.88,0.343 -1.49,0.367 -1.343,0.046 -2.37,-1.258 -3.25,-2.425 -1.756,-2.383 -3.124,-6.716 -1.29,-9.664 0.86,-1.437 2.471,-2.349 4.241,-2.402 0.763,-0.015 1.494,0.258 2.135,0.497 0.49,0.183 0.929,0.347 1.287,0.347 0.315,0 0.74,-0.157 1.237,-0.34 0.78,-0.288 1.737,-0.64 2.71,-0.545 1.514,0.044 2.917,0.748 3.785,1.9"
android:fillColor="#FF000000" android:fillColor="#FF000000"
android:fillType="evenOdd"/> android:fillType="evenOdd"/>
</vector> </vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9,4c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2m0,6c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2m0,6c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2m6,0c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2m0,-6c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2m0,-6c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6.56,7.98C6.1,7.52 5.31,7.6 5,8.17c-0.28,0.51 -0.5,1.03 -0.67,1.58 -0.19,0.63 0.31,1.25 0.96,1.25h0.01c0.43,0 0.82,-0.28 0.94,-0.7q0.18,-0.6 0.48,-1.17c0.22,-0.37 0.15,-0.84 -0.16,-1.15M5.31,13h-0.02c-0.65,0 -1.15,0.62 -0.96,1.25 0.16,0.54 0.38,1.07 0.66,1.58 0.31,0.57 1.11,0.66 1.57,0.2 0.3,-0.31 0.38,-0.77 0.17,-1.15 -0.2,-0.37 -0.36,-0.76 -0.48,-1.16a0.97,0.97 0,0 0,-0.94 -0.72m2.85,6.02q0.765,0.42 1.59,0.66c0.62,0.18 1.24,-0.32 1.24,-0.96v-0.03c0,-0.43 -0.28,-0.82 -0.7,-0.94 -0.4,-0.12 -0.78,-0.28 -1.15,-0.48a0.97,0.97 0,0 0,-1.16 0.17l-0.03,0.03c-0.45,0.45 -0.36,1.24 0.21,1.55M13,4.07v-0.66c0,-0.89 -1.08,-1.34 -1.71,-0.71L9.17,4.83c-0.4,0.4 -0.4,1.04 0,1.43l2.13,2.08c0.63,0.62 1.7,0.17 1.7,-0.72V6.09c2.84,0.48 5,2.94 5,5.91 0,2.73 -1.82,5.02 -4.32,5.75a0.97,0.97 0,0 0,-0.68 0.94v0.02c0,0.65 0.61,1.14 1.23,0.96A7.976,7.976 0,0 0,20 12c0,-4.08 -3.05,-7.44 -7,-7.93"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24H0z"/>
<path
android:pathData="M14.83,4.83 L12.7,2.7c-0.62,-0.62 -1.7,-0.18 -1.7,0.71v0.66C7.06,4.56 4,7.92 4,12c0,3.64 2.43,6.71 5.77,7.68 0.62,0.18 1.23,-0.32 1.23,-0.96v-0.03a0.97,0.97 0,0 0,-0.68 -0.94A5.98,5.98 0,0 1,6 12c0,-2.97 2.16,-5.43 5,-5.91v1.53c0,0.89 1.07,1.33 1.7,0.71l2.13,-2.08a0.99,0.99 0,0 0,0 -1.42m4.84,4.93q-0.24,-0.825 -0.66,-1.59c-0.31,-0.57 -1.1,-0.66 -1.56,-0.2l-0.01,0.01c-0.31,0.31 -0.38,0.78 -0.17,1.16 0.2,0.37 0.36,0.76 0.48,1.16 0.12,0.42 0.51,0.7 0.94,0.7h0.02c0.65,0 1.15,-0.62 0.96,-1.24M13,18.68v0.02c0,0.65 0.62,1.14 1.24,0.96q0.825,-0.24 1.59,-0.66c0.57,-0.31 0.66,-1.1 0.2,-1.56l-0.02,-0.02a0.97,0.97 0,0 0,-1.16 -0.17c-0.37,0.21 -0.76,0.37 -1.16,0.49 -0.41,0.12 -0.69,0.51 -0.69,0.94m4.44,-2.65c0.46,0.46 1.25,0.37 1.56,-0.2 0.28,-0.51 0.5,-1.04 0.67,-1.59 0.18,-0.62 -0.31,-1.24 -0.96,-1.24h-0.02c-0.44,0 -0.82,0.28 -0.94,0.7q-0.18,0.6 -0.48,1.17c-0.21,0.38 -0.13,0.86 0.17,1.16"
android:fillColor="#FF000000"/>
</group>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M16,7a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,15 8q0,0.424 0.287,0.713Q15.576,9 16,9t0.712,-0.287A0.97,0.97 0,0 0,17 8a0.97,0.97 0,0 0,-0.288 -0.713A0.97,0.97 0,0 0,16 7m0,4a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,15 12q0,0.424 0.287,0.713 0.288,0.287 0.713,0.287 0.424,0 0.712,-0.287A0.97,0.97 0,0 0,17 12a0.97,0.97 0,0 0,-0.288 -0.713A0.97,0.97 0,0 0,16 11m0,4a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,15 16q0,0.424 0.287,0.712 0.288,0.288 0.713,0.288 0.424,0 0.712,-0.288A0.97,0.97 0,0 0,17 16a0.97,0.97 0,0 0,-0.288 -0.713A0.97,0.97 0,0 0,16 15m-4,-8L8,7a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,7 8q0,0.424 0.287,0.713Q7.576,9 8,9h4q0.424,0 0.713,-0.287A0.97,0.97 0,0 0,13 8a0.97,0.97 0,0 0,-0.287 -0.713A0.97,0.97 0,0 0,12 7m0,4L8,11a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,7 12q0,0.424 0.287,0.713Q7.576,13 8,13h4q0.424,0 0.713,-0.287A0.97,0.97 0,0 0,13 12a0.97,0.97 0,0 0,-0.287 -0.713A0.97,0.97 0,0 0,12 11m0,4L8,15a0.97,0.97 0,0 0,-0.713 0.287A0.97,0.97 0,0 0,7 16q0,0.424 0.287,0.712Q7.576,17 8,17h4q0.424,0 0.713,-0.288A0.97,0.97 0,0 0,13 16a0.97,0.97 0,0 0,-0.287 -0.713A0.97,0.97 0,0 0,12 15m7,-12q0.824,0 1.413,0.587Q21,4.176 21,5v14q0,0.824 -0.587,1.413A1.93,1.93 0,0 1,19 21L5,21q-0.824,0 -1.412,-0.587A1.93,1.93 0,0 1,3 19L3,5q0,-0.824 0.587,-1.412A1.93,1.93 0,0 1,5 3zM19,5L5,5v14h14z"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M16,18v2L8,20v-2zM18,16L18,8a2,2 0,0 0,-2 -2L8,6a2,2 0,0 0,-2 2v8a2,2 0,0 0,2 2v2a4,4 0,0 1,-4 -4L4,8a4,4 0,0 1,4 -4h8a4,4 0,0 1,4 4v8a4,4 0,0 1,-4 4v-2a2,2 0,0 0,2 -2"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4,8a4,4 0,0 1,4 -4h8a4,4 0,0 1,4 4v8a4,4 0,0 1,-4 4H8a4,4 0,0 1,-4 -4z"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,22c5.52,0 10,-4.48 10,-10S17.52,2 12,2 2,6.48 2,12s4.48,10 10,10m1,-17.93c3.94,0.49 7,3.85 7,7.93s-3.05,7.44 -7,7.93z"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M13,2a2,2 0,0 1,2 2v4h6a2,2 0,0 1,2 2v12.586c0,0.89 -1.077,1.337 -1.707,0.707L19,21h-8a2,2 0,0 1,-2 -2v-4L5,15l-2.293,2.293c-0.63,0.63 -1.707,0.184 -1.707,-0.707L1,4a2,2 0,0 1,2 -2zM15.5,12.125L12,12.125v1.25h4.37c-0.031,0.73 -0.325,1.457 -0.871,2.151a4.4,4.4 0,0 1,-0.613 -1.026h-1.33c0.202,0.69 0.57,1.335 1.067,1.932 -0.524,0.448 -1.162,0.873 -1.912,1.263l0.578,1.11a11.3,11.3 0,0 0,2.21 -1.483c0.633,0.553 1.382,1.05 2.212,1.483l0.578,-1.11c-0.75,-0.39 -1.388,-0.815 -1.912,-1.263 0.758,-0.912 1.213,-1.939 1.245,-3.057L20,13.375v-1.25h-3.25L16.75,10.25L15.5,10.25zM3,14.172l0.586,-0.586A2,2 0,0 1,5 13h4v-2.47L6.96,10.53L6.563,12L5,12l2.031,-7L8.97,5l0.96,3.312A2,2 0,0 1,11 8h2L13,4L3,4zM7.306,9.245h1.386l-0.67,-2.481h-0.047z"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9.01,5v1H11c0.333,0 1,0 1.5,0.5S13,7.667 13,8v7.01c0,0.54 0.45,0.99 0.99,0.99H15v-1a2,2 0,0 1,2 -2h3a2,2 0,0 1,2 2v4a2,2 0,0 1,-2 2h-3a2,2 0,0 1,-2 -2v-1h-1.01C12.34,18 11,16.66 11,15.01V9c0,-1 0,-1 -1,-1H9v1a2,2 0,0 1,-2 2H4a2,2 0,0 1,-2 -2V5c0,-1.1 0.9,-2 2,-2h3.01a2,2 0,0 1,2 2"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M16,4a2,2 0,0 1,2 2v4.286l3.35,-2.871a1,1 0,0 1,1.65 0.759v7.652a1,1 0,0 1,-1.65 0.759L18,13.714V18a2,2 0,0 1,-2 2H6a4,4 0,0 1,-4 -4V8a4,4 0,0 1,4 -4zM9.55,9l-0.103,0.005a1,1 0,0 0,0 1.99L9.55,11h0.571l-2.828,2.828a1,1 0,0 0,1.414 1.414L11.55,12.4v0.6l0.005,0.102a1,1 0,0 0,1.99 0L13.55,13v-3l-0.005,-0.103A1,1 0,0 0,12.55 9z"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -4,12 +4,8 @@
android:autoMirrored="true" android:autoMirrored="true"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<group> <path
<clip-path android:pathData="M8.929,15.1a13.6,13.6 0,0 0,4.654 3.066q2.62,1.036 5.492,0.923h0.008l0.003,-0.004 0.003,-0.002 -0.034,-3.124 -3.52,-0.483 -1.791,1.792 -0.645,-0.322a13.5,13.5 0,0 1,-3.496 -2.52,13.4 13.4,0 0,1 -2.52,-3.496l-0.322,-0.645 1.792,-1.791 -0.483,-3.52 -3.123,-0.033 -0.003,0.002 -0.003,0.004v0.002a13.65,13.65 0,0 0,0.932 5.492A13.4,13.4 0,0 0,8.93 15.1m3.92,4.926a15.6,15.6 0,0 1,-5.334 -3.511,15.4 15.4,0 0,1 -3.505,-5.346 15.6,15.6 0,0 1,-1.069 -6.274,1.93 1.93,0 0,1 0.589,-1.366c0.366,-0.366 0.84,-0.589 1.386,-0.589h0.01l3.163,0.035a1.96,1.96 0,0 1,1.958 1.694v0.005l0.487,3.545v0.003c0.043,0.297 0.025,0.605 -0.076,0.907a2,2 0,0 1,-0.485 0.773l-0.762,0.762a11.3,11.3 0,0 0,1.806 2.348,11.4 11.4,0 0,0 2.348,1.806l0.762,-0.762a2,2 0,0 1,0.774 -0.485c0.302,-0.1 0.61,-0.118 0.907,-0.076l3.553,0.487a1.96,1.96 0,0 1,1.694 1.958l0.034,3.174c0,0.546 -0.223,1.02 -0.588,1.386 -0.36,0.36 -0.827,0.582 -1.363,0.588a15.3,15.3 0,0 1,-6.29 -1.062"
android:pathData="M0,0h24v24H0z"/> android:fillColor="#FF000000"
<path android:fillType="evenOdd"/>
android:pathData="M8.929,15.1a13.6,13.6 0,0 0,4.654 3.066q2.62,1.036 5.492,0.923h0.008l0.003,-0.004 0.003,-0.002 -0.034,-3.124 -3.52,-0.483 -1.791,1.792 -0.645,-0.322a13.5,13.5 0,0 1,-3.496 -2.52,13.4 13.4,0 0,1 -2.52,-3.496l-0.322,-0.644 1.792,-1.792 -0.483,-3.519 -3.123,-0.034 -0.003,0.002 -0.003,0.004v0.002a13.65,13.65 0,0 0,0.932 5.492A13.4,13.4 0,0 0,8.93 15.1m3.92,4.926a15.6,15.6 0,0 1,-5.334 -3.511,15.4 15.4,0 0,1 -3.505,-5.346 15.6,15.6 0,0 1,-1.069 -6.274,1.93 1.93,0 0,1 0.589,-1.366c0.366,-0.366 0.84,-0.589 1.386,-0.589h0.01l3.163,0.035a1.96,1.96 0,0 1,1.958 1.694v0.005l0.487,3.545v0.003c0.043,0.297 0.025,0.605 -0.076,0.907a2,2 0,0 1,-0.485 0.773l-0.762,0.762a11.4,11.4 0,0 0,3.206 3.54q0.457,0.33 0.948,0.614l0.762,-0.761a2,2 0,0 1,0.774 -0.486c0.302,-0.1 0.61,-0.118 0.907,-0.076l3.553,0.487a1.96,1.96 0,0 1,1.694 1.958l0.034,3.174c0,0.546 -0.223,1.02 -0.588,1.386 -0.361,0.36 -0.827,0.582 -1.363,0.588a15.3,15.3 0,0 1,-6.29 -1.062"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
</group>
</vector> </vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M7.623,3.04a1.07,1.07 0,0 1,1.086 0.929l0.542,3.954q0.039,0.27 -0.038,0.504a1.1,1.1 0,0 1,-0.272 0.427l-1.64,1.64Q7.806,11.5 8.456,12.4c0.433,0.601 1.444,1.697 1.444,1.697 0.013,0.012 1.098,1.014 1.696,1.444q0.9,0.65 1.909,1.153l1.64,-1.64q0.194,-0.194 0.426,-0.27a1.1,1.1 0,0 1,0.504 -0.04l3.953,0.543q0.407,0.058 0.67,0.358 0.26,0.301 0.26,0.728l0.04,3.527q0,0.427 -0.33,0.756 -0.33,0.33 -0.756,0.33a16,16 0,0 1,-6.57 -1.105,16.2 16.2,0 0,1 -5.563,-3.663 16.1,16.1 0,0 1,-3.653 -5.573,16.3 16.3,0 0,1 -1.116,-6.56q0,-0.426 0.329,-0.756Q3.67,3 4.095,3zM20.25,3q0.405,0 0.707,0.3 0.3,0.301 0.3,0.708t-0.3,0.707l-1.414,1.414 1.414,1.414q0.3,0.3 0.3,0.707t-0.3,0.707 -0.707,0.3 -0.707,-0.3l-1.414,-1.414 -1.414,1.414q-0.3,0.3 -0.707,0.3t-0.707,-0.3T15,8.25q0,-0.406 0.3,-0.707l1.415,-1.414L15.3,4.715q-0.3,-0.3 -0.301,-0.707 0,-0.407 0.3,-0.707t0.71,-0.301q0.405,0 0.707,0.3l1.414,1.415L19.543,3.3q0.3,-0.3 0.707,-0.301"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M7.623,3.04a1.07,1.07 0,0 1,1.086 0.929l0.542,3.954q0.039,0.27 -0.038,0.504a1.1,1.1 0,0 1,-0.272 0.427l-1.64,1.64Q7.806,11.5 8.456,12.4c0.433,0.601 1.444,1.697 1.444,1.697 0.013,0.012 1.098,1.014 1.696,1.444q0.9,0.65 1.909,1.153l1.64,-1.64q0.194,-0.194 0.426,-0.27a1.1,1.1 0,0 1,0.504 -0.04l3.953,0.543q0.407,0.058 0.67,0.358 0.26,0.301 0.26,0.728l0.04,3.527q0,0.427 -0.33,0.756 -0.33,0.33 -0.756,0.33a16,16 0,0 1,-6.57 -1.105,16.2 16.2,0 0,1 -5.563,-3.663 16.1,16.1 0,0 1,-3.653 -5.573,16.3 16.3,0 0,1 -1.116,-6.56q0,-0.426 0.329,-0.756Q3.67,3 4.095,3z"
android:fillColor="#FF000000"/>
<path
android:pathData="M16,5q0.425,0 0.713,0.287Q17,5.575 17,6a0.97,0.97 0,0 1,-0.287 0.713A0.97,0.97 0,0 1,16 7h-0.5l2.2,2.15 2.4,-2.4a0.95,0.95 0,0 1,0.7 -0.275,0.95 0.95,0 0,1 0.7,0.275q0.3,0.3 0.3,0.7a0.92,0.92 0,0 1,-0.275 0.675l-3.125,3.15a0.8,0.8 0,0 1,-0.312 0.225,1.04 1.04,0 0,1 -0.776,0 0.9,0.9 0,0 1,-0.312 -0.2l-3,-3V9a0.97,0.97 0,0 1,-0.287 0.713A0.97,0.97 0,0 1,13 10a0.97,0.97 0,0 1,-0.713 -0.287A0.97,0.97 0,0 1,12 9V6q0,-0.425 0.287,-0.713A0.97,0.97 0,0 1,13 5z"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M7.623,3.04a1.07,1.07 0,0 1,1.086 0.929l0.542,3.954q0.039,0.27 -0.038,0.504a1.1,1.1 0,0 1,-0.272 0.427l-1.64,1.64Q7.806,11.5 8.456,12.4c0.433,0.601 1.444,1.697 1.444,1.697 0.013,0.012 1.098,1.014 1.696,1.444q0.9,0.65 1.909,1.153l1.64,-1.64q0.194,-0.194 0.426,-0.27a1.1,1.1 0,0 1,0.504 -0.04l3.953,0.543q0.407,0.058 0.67,0.358 0.26,0.301 0.26,0.728l0.04,3.527q0,0.427 -0.33,0.756 -0.33,0.33 -0.756,0.33a16,16 0,0 1,-6.57 -1.105,16.2 16.2,0 0,1 -5.563,-3.663 16.1,16.1 0,0 1,-3.653 -5.573,16.3 16.3,0 0,1 -1.116,-6.56q0,-0.426 0.329,-0.756Q3.67,3 4.095,3z"
android:fillColor="#FF000000"/>
<path
android:pathData="M19.964,3a1,1 0,0 1,0.995 0.897l0.005,0.103v3l-0.005,0.103a1,1 0,0 1,-1.99 0L18.964,7v-0.605l-4.05,4.02A1,1 0,0 1,13.5 9l4.03,-4h-0.566l-0.103,-0.005a1,1 0,0 1,0 -1.99L16.964,3z"
android:fillColor="#FF000000"/>
</vector>

View file

@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24H0z"/>
<path
android:pathData="M10.5,6.5q0.425,0 0.713,0.287 0.288,0.288 0.287,0.713v2h2q0.425,0 0.713,0.287 0.288,0.288 0.287,0.713a0.97,0.97 0,0 1,-0.287 0.713,0.97 0.97,0 0,1 -0.713,0.287h-2v2a0.97,0.97 0,0 1,-0.287 0.713,0.97 0.97,0 0,1 -0.713,0.287 0.97,0.97 0,0 1,-0.713 -0.287,0.97 0.97,0 0,1 -0.287,-0.713v-2h-2a0.97,0.97 0,0 1,-0.713 -0.287,0.97 0.97,0 0,1 -0.287,-0.713q0,-0.425 0.287,-0.713A0.97,0.97 0,0 1,7.5 9.5h2v-2q0,-0.425 0.287,-0.713A0.97,0.97 0,0 1,10.5 6.5"
android:fillColor="#FF000000"/>
<path
android:pathData="M10.5,3a7.5,7.5 0,0 1,5.963 12.049l3.244,3.244a1,1 0,1 1,-1.414 1.414l-3.244,-3.244A7.5,7.5 0,1 1,10.5 3m0,2a5.5,5.5 0,1 0,0 11,5.5 5.5,0 0,0 0,-11"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
<path
android:pathData="M15.05,16.463a7.5,7.5 0,1 1,1.414 -1.414l3.243,3.244a1,1 0,0 1,-1.414 1.414zM16,10.5a5.5,5.5 0,1 0,-11 0,5.5 5.5,0 0,0 11,0"
android:fillColor="#FF000000"/>
<path
android:pathData="M7.875,11.375h1.75v1.75q0,0.372 0.252,0.623A0.85,0.85 0,0 0,10.5 14a0.85,0.85 0,0 0,0.623 -0.252,0.85 0.85,0 0,0 0.252,-0.623v-1.75h1.75a0.85,0.85 0,0 0,0.623 -0.252A0.85,0.85 0,0 0,14 10.5a0.85,0.85 0,0 0,-0.252 -0.623,0.85 0.85,0 0,0 -0.623,-0.252h-1.75v-1.75a0.85,0.85 0,0 0,-0.252 -0.623A0.85,0.85 0,0 0,10.5 7a0.85,0.85 0,0 0,-0.623 0.252,0.85 0.85,0 0,0 -0.252,0.623v1.75h-1.75a0.85,0.85 0,0 0,-0.623 0.252A0.85,0.85 0,0 0,7 10.5q0,0.372 0.252,0.623a0.85,0.85 0,0 0,0.623 0.252"
android:fillColor="#FF000000"/>
</group>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M13.5,9.5q0.425,0 0.713,0.287 0.288,0.288 0.287,0.713a0.97,0.97 0,0 1,-0.287 0.713,0.97 0.97,0 0,1 -0.713,0.287h-6a0.97,0.97 0,0 1,-0.713 -0.287,0.97 0.97,0 0,1 -0.287,-0.713q0,-0.425 0.287,-0.713A0.97,0.97 0,0 1,7.5 9.5z"
android:fillColor="#FF000000"/>
<path
android:pathData="M10.5,3a7.5,7.5 0,0 1,5.963 12.049l3.244,3.244a1,1 0,1 1,-1.414 1.414l-3.244,-3.244A7.5,7.5 0,1 1,10.5 3m0,2a5.5,5.5 0,1 0,0 11,5.5 5.5,0 0,0 0,-11"
android:fillColor="#FF000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -26,6 +26,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.colors.gradientCriticalColors
import io.element.android.libraries.designsystem.colors.gradientInfoColors
import io.element.android.libraries.designsystem.components.avatar.Avatar 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.avatar.AvatarType
@ -38,13 +40,16 @@ 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.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
/**
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2392-6721
*/
@Composable @Composable
fun ComposerAlertMolecule( fun ComposerAlertMolecule(
avatar: AvatarData?, avatar: AvatarData?,
content: AnnotatedString, content: AnnotatedString,
onSubmitClick: () -> Unit, onSubmitClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
level: ComposerAlertLevel = ComposerAlertLevel.Default, level: ComposerAlertLevel = ComposerAlertLevel.Info,
showIcon: Boolean = false, showIcon: Boolean = false,
submitText: String = stringResource(CommonStrings.action_ok), submitText: String = stringResource(CommonStrings.action_ok),
) { ) {
@ -52,20 +57,12 @@ fun ComposerAlertMolecule(
modifier.fillMaxWidth() modifier.fillMaxWidth()
) { ) {
val lineColor = when (level) { val lineColor = when (level) {
ComposerAlertLevel.Default -> ElementTheme.colors.borderInfoSubtle
ComposerAlertLevel.Info -> ElementTheme.colors.borderInfoSubtle ComposerAlertLevel.Info -> ElementTheme.colors.borderInfoSubtle
ComposerAlertLevel.Critical -> ElementTheme.colors.borderCriticalSubtle ComposerAlertLevel.Critical -> ElementTheme.colors.borderCriticalSubtle
} }
val startColor = when (level) {
ComposerAlertLevel.Default -> ElementTheme.colors.bgInfoSubtle
ComposerAlertLevel.Info -> ElementTheme.colors.bgInfoSubtle
ComposerAlertLevel.Critical -> ElementTheme.colors.bgCriticalSubtle
}
val textColor = when (level) { val textColor = when (level) {
ComposerAlertLevel.Default -> ElementTheme.colors.textPrimary ComposerAlertLevel.Info -> ElementTheme.colors.textPrimary
ComposerAlertLevel.Info -> ElementTheme.colors.textInfoPrimary
ComposerAlertLevel.Critical -> ElementTheme.colors.textCriticalPrimary ComposerAlertLevel.Critical -> ElementTheme.colors.textCriticalPrimary
} }
@ -75,12 +72,13 @@ fun ComposerAlertMolecule(
.height(1.dp) .height(1.dp)
.background(lineColor) .background(lineColor)
) )
val brush = Brush.verticalGradient( val gradientColors = when (level) {
listOf(startColor, ElementTheme.colors.bgCanvasDefault), ComposerAlertLevel.Info -> gradientInfoColors()
) ComposerAlertLevel.Critical -> gradientCriticalColors()
}
Box( Box(
modifier = Modifier modifier = Modifier
.background(brush) .background(Brush.verticalGradient(gradientColors))
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp) .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
) { ) {
Column( Column(
@ -96,12 +94,10 @@ fun ComposerAlertMolecule(
) )
} else if (showIcon) { } else if (showIcon) {
val icon = when (level) { val icon = when (level) {
ComposerAlertLevel.Default -> CompoundIcons.Info()
ComposerAlertLevel.Info -> CompoundIcons.Info() ComposerAlertLevel.Info -> CompoundIcons.Info()
ComposerAlertLevel.Critical -> CompoundIcons.Error() ComposerAlertLevel.Critical -> CompoundIcons.Error()
} }
val iconTint = when (level) { val iconTint = when (level) {
ComposerAlertLevel.Default -> ElementTheme.colors.iconPrimary
ComposerAlertLevel.Info -> ElementTheme.colors.iconInfoPrimary ComposerAlertLevel.Info -> ElementTheme.colors.iconInfoPrimary
ComposerAlertLevel.Critical -> ElementTheme.colors.iconCriticalPrimary ComposerAlertLevel.Critical -> ElementTheme.colors.iconCriticalPrimary
} }
@ -131,7 +127,6 @@ fun ComposerAlertMolecule(
} }
enum class ComposerAlertLevel { enum class ComposerAlertLevel {
Default,
Info, Info,
Critical Critical
} }

View file

@ -21,7 +21,6 @@ internal data class ComposerAlertMoleculeParams(
internal class ComposerAlertMoleculeParamsProvider : PreviewParameterProvider<ComposerAlertMoleculeParams> { internal class ComposerAlertMoleculeParamsProvider : PreviewParameterProvider<ComposerAlertMoleculeParams> {
private val allLevels = sequenceOf( private val allLevels = sequenceOf(
ComposerAlertLevel.Default,
ComposerAlertLevel.Info, ComposerAlertLevel.Info,
ComposerAlertLevel.Critical ComposerAlertLevel.Critical
) )

View file

@ -38,8 +38,11 @@ fun gradientSubtleColors(): List<Color> = listOf(
fun gradientInfoColors(): List<Color> = listOf( fun gradientInfoColors(): List<Color> = listOf(
ElementTheme.colors.gradientInfoStop1, ElementTheme.colors.gradientInfoStop1,
ElementTheme.colors.gradientInfoStop2, ElementTheme.colors.gradientInfoStop2,
ElementTheme.colors.gradientInfoStop3, )
ElementTheme.colors.gradientInfoStop4,
ElementTheme.colors.gradientInfoStop5, @Composable
ElementTheme.colors.gradientInfoStop6, @ReadOnlyComposable
fun gradientCriticalColors(): List<Color> = listOf(
ElementTheme.colors.gradientCriticalStop1,
ElementTheme.colors.gradientCriticalStop2,
) )

View file

@ -14,7 +14,6 @@ import io.element.android.libraries.designsystem.R
// All the icons should be defined in Compound. // All the icons should be defined in Compound.
internal val iconsOther = listOf( internal val iconsOther = listOf(
R.drawable.ic_notification, R.drawable.ic_notification,
R.drawable.ic_stop,
R.drawable.pin, R.drawable.pin,
R.drawable.ic_winner, R.drawable.ic_winner,
) )

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6,16V8C6,7.45 6.196,6.979 6.588,6.588C6.979,6.196 7.45,6 8,6H16C16.55,6 17.021,6.196 17.413,6.588C17.804,6.979 18,7.45 18,8V16C18,16.55 17.804,17.021 17.413,17.413C17.021,17.804 16.55,18 16,18H8C7.45,18 6.979,17.804 6.588,17.413C6.196,17.021 6,16.55 6,16Z"
android:fillColor="#ffffff"/>
</vector>

View file

@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.api.media package io.element.android.libraries.matrix.api.media
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@ -16,9 +17,20 @@ data class MediaSource(
/** /**
* Url of the media. * Url of the media.
*/ */
val url: String, private val url: String,
/** /**
* This is used to hold data for encrypted media. * This is used to hold data for encrypted media.
*/ */
val json: String? = null, val json: String? = null,
) : Parcelable ) : Parcelable {
/**
* A URL with invalid parts (like `#fragment`, if it's an MXC url) removed.
*/
@IgnoredOnParcel
val safeUrl = if (url.startsWith("mxc")) {
// We've seen some MXC urls in the wild having some `mxc://foo/bar#auto` fragment suffix, which is invalid
url.substringBefore("#")
} else {
url
}
}

View file

@ -35,6 +35,11 @@ interface RoomListService {
data object Hide : SyncIndicator data object Hide : SyncIndicator
} }
/**
* Indicates whether the initial sliding sync request is done or not.
*/
val isInitialSyncDone: Boolean
/** /**
* Creates a room list that can be used to load more rooms and filter them dynamically. * Creates a room list that can be used to load more rooms and filter them dynamically.
* @param pageSize the number of rooms to load at once. * @param pageSize the number of rooms to load at once.

View file

@ -0,0 +1,26 @@
/*
* 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.matrix.api.media
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.media.aMediaSource
import org.junit.Test
class MediaSourceTest {
@Test
fun `safeUrl removes the fragment part in MXC urls`() {
val mediaSource = aMediaSource(url = "mxc://matrix.org/url#fragment")
assertThat(mediaSource.safeUrl).isEqualTo("mxc://matrix.org/url")
}
@Test
fun `safeUrl keeps the fragment part in a non-MXC url`() {
val mediaSource = aMediaSource(url = "https://matrix.org/url#fragment")
assertThat(mediaSource.safeUrl).isEqualTo("https://matrix.org/url#fragment")
}
}

View file

@ -83,7 +83,7 @@ import io.element.android.libraries.matrix.impl.util.SessionPathsProvider
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
import io.element.android.libraries.matrix.impl.workmanager.PerformDatabaseVacuumWorkManagerRequest import io.element.android.libraries.matrix.impl.workmanager.PerformDatabaseVacuumRequestBuilder
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerRequestType
import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.libraries.workmanager.api.WorkManagerScheduler
@ -832,8 +832,8 @@ class RustMatrixClient(
if (workManagerScheduler.hasPendingWork(sessionId, WorkManagerRequestType.DB_VACUUM)) return if (workManagerScheduler.hasPendingWork(sessionId, WorkManagerRequestType.DB_VACUUM)) return
Timber.i("Scheduling periodic database vacuuming for session $sessionId") Timber.i("Scheduling periodic database vacuuming for session $sessionId")
val request = PerformDatabaseVacuumWorkManagerRequest(sessionId) val request = PerformDatabaseVacuumRequestBuilder(sessionId)
workManagerScheduler.submit(request) sessionCoroutineScope.launch { workManagerScheduler.submit(request) }
} }
} }

View file

@ -91,7 +91,7 @@ class RustMediaLoader(
return if (json != null) { return if (json != null) {
RustMediaSource.fromJson(json) RustMediaSource.fromJson(json)
} else { } else {
RustMediaSource.fromUrl(url) RustMediaSource.fromUrl(safeUrl)
} }
} }
} }

View file

@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.stateIn
import org.matrix.rustcomponents.sdk.RoomListServiceState import org.matrix.rustcomponents.sdk.RoomListServiceState
import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService
internal class RustRoomListService( internal class RustRoomListService(
@ -33,6 +34,9 @@ internal class RustRoomListService(
private val roomSyncSubscriber: RoomSyncSubscriber, private val roomSyncSubscriber: RoomSyncSubscriber,
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
) : RoomListService { ) : RoomListService {
private val _isInitialSyncDone = AtomicBoolean(false)
override val isInitialSyncDone: Boolean get() = _isInitialSyncDone.get()
override fun createRoomList( override fun createRoomList(
pageSize: Int, pageSize: Int,
source: RoomList.Source, source: RoomList.Source,
@ -75,6 +79,9 @@ internal class RustRoomListService(
.map { it.toRoomListState() } .map { it.toRoomListState() }
.onEach { state -> .onEach { state ->
Timber.d("RoomList state=$state") Timber.d("RoomList state=$state")
if (state == RoomListService.State.Running) {
_isInitialSyncDone.set(true)
}
} }
.distinctUntilChanged() .distinctUntilChanged()
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, RoomListService.State.Idle) .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, RoomListService.State.Idle)

View file

@ -10,18 +10,18 @@ package io.element.android.libraries.matrix.impl.workmanager
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.Data import androidx.work.Data
import androidx.work.PeriodicWorkRequest import androidx.work.PeriodicWorkRequest
import androidx.work.WorkRequest
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.impl.workmanager.VacuumDatabaseWorker.Companion.SESSION_ID_PARAM import io.element.android.libraries.matrix.impl.workmanager.VacuumDatabaseWorker.Companion.SESSION_ID_PARAM
import io.element.android.libraries.workmanager.api.WorkManagerRequest import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerRequestType
import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper
import io.element.android.libraries.workmanager.api.workManagerTag import io.element.android.libraries.workmanager.api.workManagerTag
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class PerformDatabaseVacuumWorkManagerRequest( class PerformDatabaseVacuumRequestBuilder(
private val sessionId: SessionId, private val sessionId: SessionId,
) : WorkManagerRequest { ) : WorkManagerRequestBuilder {
override fun build(): Result<List<WorkRequest>> { override suspend fun build(): Result<List<WorkManagerRequestWrapper>> {
val data = Data.Builder().putString(SESSION_ID_PARAM, sessionId.value).build() val data = Data.Builder().putString(SESSION_ID_PARAM, sessionId.value).build()
val workRequest = PeriodicWorkRequest.Builder( val workRequest = PeriodicWorkRequest.Builder(
workerClass = VacuumDatabaseWorker::class, workerClass = VacuumDatabaseWorker::class,
@ -41,6 +41,6 @@ class PerformDatabaseVacuumWorkManagerRequest(
) )
.build() .build()
return Result.success(listOf(workRequest)) return Result.success(listOf(WorkManagerRequestWrapper(workRequest)))
} }
} }

View file

@ -19,7 +19,7 @@ import io.element.android.libraries.network.useragent.SimpleUserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.libraries.workmanager.api.WorkManagerRequest import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
@ -33,7 +33,7 @@ import java.io.File
class RustMatrixClientFactoryTest { class RustMatrixClientFactoryTest {
@Test @Test
fun test() = runTest { fun test() = runTest {
val scheduleVacuumLambda = lambdaRecorder<WorkManagerRequest, Unit> {} val scheduleVacuumLambda = lambdaRecorder<WorkManagerRequestBuilder, Unit> {}
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda) val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda)
val sut = createRustMatrixClientFactory(workManagerScheduler = workManagerScheduler) val sut = createRustMatrixClientFactory(workManagerScheduler = workManagerScheduler)

View file

@ -20,10 +20,14 @@ class FakeRoomListService(
private val subscribeToVisibleRoomsLambda: (List<RoomId>) -> Unit = {}, private val subscribeToVisibleRoomsLambda: (List<RoomId>) -> Unit = {},
private val createRoomListLambda: (pageSize: Int) -> DynamicRoomList = { pageSize -> FakeDynamicRoomList(pageSize = pageSize) }, private val createRoomListLambda: (pageSize: Int) -> DynamicRoomList = { pageSize -> FakeDynamicRoomList(pageSize = pageSize) },
override val allRooms: RoomList = createRoomListLambda(Int.MAX_VALUE), override val allRooms: RoomList = createRoomListLambda(Int.MAX_VALUE),
private val isInitialSyncLambda: () -> Boolean = { true },
) : RoomListService { ) : RoomListService {
private val roomListStateFlow = MutableStateFlow<RoomListService.State>(RoomListService.State.Idle) private val roomListStateFlow = MutableStateFlow<RoomListService.State>(RoomListService.State.Idle)
private val syncIndicatorStateFlow = MutableStateFlow<RoomListService.SyncIndicator>(RoomListService.SyncIndicator.Hide) private val syncIndicatorStateFlow = MutableStateFlow<RoomListService.SyncIndicator>(RoomListService.SyncIndicator.Hide)
override val isInitialSyncDone: Boolean
get() = isInitialSyncLambda()
suspend fun postState(state: RoomListService.State) { suspend fun postState(state: RoomListService.State) {
roomListStateFlow.emit(state) roomListStateFlow.emit(state)
} }

View file

@ -27,15 +27,15 @@ internal class CoilMediaFetcher(
private val mediaData: MediaRequestData, private val mediaData: MediaRequestData,
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
val source = mediaData.source val mediaSource = mediaData.source
if (source == null) { if (mediaSource == null) {
Timber.e("MediaData source is null") Timber.e("MediaData source is null")
return null return null
} }
return when (val kind = mediaData.kind) { return when (val kind = mediaData.kind) {
is MediaRequestData.Kind.Content -> fetchContent(source) is MediaRequestData.Kind.Content -> fetchContent(mediaSource)
is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(source, kind) is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(mediaSource, kind)
is MediaRequestData.Kind.File -> fetchFile(source, kind) is MediaRequestData.Kind.File -> fetchFile(mediaSource, kind)
} }
} }

View file

@ -25,5 +25,5 @@ internal class MediaRequestDataKeyer : Keyer<MediaRequestData> {
} }
private fun MediaRequestData.toKey(): String? { private fun MediaRequestData.toKey(): String? {
return source?.let { "${it.url}_$kind" } return source?.let { "${it.safeUrl}_$kind" }
} }

View file

@ -126,7 +126,7 @@ class MediaViewerDataSource(
when (mediaItem) { when (mediaItem) {
is MediaItem.DateSeparator -> Unit is MediaItem.DateSeparator -> Unit
is MediaItem.Event -> { is MediaItem.Event -> {
val sourceUrl = mediaItem.mediaSource().url val sourceUrl = mediaItem.mediaSource().safeUrl
val localMedia = localMediaStates.getOrPut(sourceUrl) { val localMedia = localMediaStates.getOrPut(sourceUrl) {
mutableStateOf(AsyncData.Uninitialized) mutableStateOf(AsyncData.Uninitialized)
} }
@ -153,7 +153,7 @@ class MediaViewerDataSource(
}.toImmutableList() }.toImmutableList()
fun clearLoadingError(data: MediaViewerPageData.MediaViewerData) { fun clearLoadingError(data: MediaViewerPageData.MediaViewerData) {
localMediaStates[data.mediaSource.url]?.value = AsyncData.Uninitialized localMediaStates[data.mediaSource.safeUrl]?.value = AsyncData.Uninitialized
} }
suspend fun loadMore(direction: Timeline.PaginationDirection) { suspend fun loadMore(direction: Timeline.PaginationDirection) {
@ -162,7 +162,7 @@ class MediaViewerDataSource(
suspend fun loadMedia(data: MediaViewerPageData.MediaViewerData) { suspend fun loadMedia(data: MediaViewerPageData.MediaViewerData) {
Timber.d("loadMedia for ${data.eventId}") Timber.d("loadMedia for ${data.eventId}")
val localMediaState = localMediaStates.getOrPut(data.mediaSource.url) { val localMediaState = localMediaStates.getOrPut(data.mediaSource.safeUrl) {
mutableStateOf(AsyncData.Uninitialized) mutableStateOf(AsyncData.Uninitialized)
} }
localMediaState.value = AsyncData.Loading() localMediaState.value = AsyncData.Loading()

View file

@ -10,8 +10,8 @@ package io.element.android.libraries.permissions.impl.troubleshoot
import android.Manifest import android.Manifest
import android.os.Build import android.os.Build
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet import dev.zacsweers.metro.ContributesIntoSet
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.permissions.api.PermissionStateProvider import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.libraries.permissions.impl.R import io.element.android.libraries.permissions.impl.R
import io.element.android.libraries.permissions.impl.action.PermissionActions import io.element.android.libraries.permissions.impl.action.PermissionActions
@ -24,7 +24,7 @@ import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ContributesIntoSet(AppScope::class) @ContributesIntoSet(SessionScope::class)
class NotificationTroubleshootCheckPermissionTest( class NotificationTroubleshootCheckPermissionTest(
private val permissionStateProvider: PermissionStateProvider, private val permissionStateProvider: PermissionStateProvider,
private val sdkVersionProvider: BuildVersionSdkIntProvider, private val sdkVersionProvider: BuildVersionSdkIntProvider,

View file

@ -1,20 +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.api.push
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
data class NotificationEventRequest(
val sessionId: SessionId,
val roomId: RoomId,
val eventId: EventId,
val providerInfo: String,
)

View file

@ -1,13 +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.api.push
fun interface SyncOnNotifiableEvent {
suspend operator fun invoke(requests: List<NotificationEventRequest>)
}

View file

@ -97,6 +97,7 @@ sqldelight {
databases { databases {
create("PushDatabase") { create("PushDatabase") {
schemaOutputDirectory = File("src/main/sqldelight/databases") schemaOutputDirectory = File("src/main/sqldelight/databases")
verifyMigrations = true
} }
} }
} }

View file

@ -14,13 +14,16 @@ import android.os.PowerManager
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.impl.PushDatabase import io.element.android.libraries.push.impl.PushDatabase
import io.element.android.libraries.push.impl.db.PushHistory import io.element.android.libraries.push.impl.db.PushHistory
import io.element.android.libraries.push.impl.db.PushRequest
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlin.time.Instant
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultPushHistoryService( class DefaultPushHistoryService(
@ -31,7 +34,37 @@ class DefaultPushHistoryService(
private val powerManager = context.getSystemService<PowerManager>() private val powerManager = context.getSystemService<PowerManager>()
private val packageName = context.packageName private val packageName = context.packageName
override fun onPushReceived( override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result<Unit> {
return runCatchingExceptions { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() }
}
override suspend fun insertOrUpdatePushRequests(pushRequests: List<PushRequest>): Result<Unit> {
return runCatchingExceptions {
pushDatabase.transaction {
for (request in pushRequests) {
pushDatabase.pushRequestQueries.insertPushRequest(request)
}
}
}
}
override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result<List<PushRequest>> {
return runCatchingExceptions {
pushDatabase.transactionWithResult {
val sinceTimeMillis = since?.toEpochMilliseconds() ?: 0
pushDatabase.pushRequestQueries.selectAllPendingForSession(sessionId.value, sinceTimeMillis).executeAsList()
}
}
}
override suspend fun removeOldPushRequests(sessionId: SessionId): Result<Unit> {
return runCatchingExceptions {
val keepAmount = 100L
pushDatabase.pushRequestQueries.removeOldest(keepAmount)
}
}
override fun onPushResult(
providerInfo: String, providerInfo: String,
eventId: EventId?, eventId: EventId?,
roomId: RoomId?, roomId: RoomId?,

View file

@ -11,13 +11,16 @@ package io.element.android.libraries.push.impl.history
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.impl.db.PushRequest
import io.element.android.libraries.push.impl.push.PushRequestStatus
import kotlin.time.Instant
interface PushHistoryService { interface PushHistoryService {
/** /**
* Create a new push history entry. * Create a new push history entry.
* Do not use directly, prefer using the extension functions. * Do not use directly, prefer using the extension functions.
*/ */
fun onPushReceived( fun onPushResult(
providerInfo: String, providerInfo: String,
eventId: EventId?, eventId: EventId?,
roomId: RoomId?, roomId: RoomId?,
@ -26,12 +29,33 @@ interface PushHistoryService {
includeDeviceState: Boolean, includeDeviceState: Boolean,
comment: String?, comment: String?,
) )
/**
* Adds or replaces an existing [PushRequest] in the local database.
*/
suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result<Unit>
/**
* Replace a list of [PushRequest] in the database.
*/
suspend fun insertOrUpdatePushRequests(pushRequests: List<PushRequest>): Result<Unit>
/**
* Gets [PushRequestStatus.PENDING] push requests from the local database for a [SessionId].
* A [since] param can optionally be provided to only return those received after that date.
*/
suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result<List<PushRequest>>
/**
* Removes the oldest push requests for a [SessionId].
*/
suspend fun removeOldPushRequests(sessionId: SessionId): Result<Unit>
} }
fun PushHistoryService.onInvalidPushReceived( fun PushHistoryService.onInvalidPushReceived(
providerInfo: String, providerInfo: String,
data: String, data: String,
) = onPushReceived( ) = onPushResult(
providerInfo = providerInfo, providerInfo = providerInfo,
eventId = null, eventId = null,
roomId = null, roomId = null,
@ -46,7 +70,7 @@ fun PushHistoryService.onUnableToRetrieveSession(
eventId: EventId, eventId: EventId,
roomId: RoomId, roomId: RoomId,
reason: String, reason: String,
) = onPushReceived( ) = onPushResult(
providerInfo = providerInfo, providerInfo = providerInfo,
eventId = eventId, eventId = eventId,
roomId = roomId, roomId = roomId,
@ -62,7 +86,7 @@ fun PushHistoryService.onUnableToResolveEvent(
roomId: RoomId, roomId: RoomId,
sessionId: SessionId, sessionId: SessionId,
reason: String, reason: String,
) = onPushReceived( ) = onPushResult(
providerInfo = providerInfo, providerInfo = providerInfo,
eventId = eventId, eventId = eventId,
roomId = roomId, roomId = roomId,
@ -78,7 +102,7 @@ fun PushHistoryService.onSuccess(
roomId: RoomId, roomId: RoomId,
sessionId: SessionId, sessionId: SessionId,
comment: String?, comment: String?,
) = onPushReceived( ) = onPushResult(
providerInfo = providerInfo, providerInfo = providerInfo,
eventId = eventId, eventId = eventId,
roomId = roomId, roomId = roomId,
@ -95,7 +119,7 @@ fun PushHistoryService.onSuccess(
fun PushHistoryService.onDiagnosticPush( fun PushHistoryService.onDiagnosticPush(
providerInfo: String, providerInfo: String,
) = onPushReceived( ) = onPushResult(
providerInfo = providerInfo, providerInfo = providerInfo,
eventId = null, eventId = null,
roomId = null, roomId = null,

View file

@ -50,8 +50,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText import io.element.android.libraries.matrix.ui.messages.toPlainText
import io.element.android.libraries.push.api.push.NotificationEventRequest
import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.db.PushRequest
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
@ -64,10 +64,10 @@ private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.No
/** /**
* Result of resolving a batch of push events. * Result of resolving a batch of push events.
* The outermost [Result] indicates whether the setup to resolve the events was successful. * The outermost [Result] indicates whether the setup to resolve the events was successful.
* The results for each push notification will be a map of [NotificationEventRequest] to [Result] of [ResolvedPushEvent]. * The results for each push notification will be a map of [PushRequest] to [Result] of [ResolvedPushEvent].
* If the resolution of a specific event fails, the innermost [Result] will contain an exception. * If the resolution of a specific event fails, the innermost [Result] will contain an exception.
*/ */
typealias ResolvePushEventsResult = Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> typealias ResolvePushEventsResult = Result<Map<PushRequest, Result<ResolvedPushEvent>>>
/** /**
* The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event. * The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event.
@ -78,7 +78,7 @@ typealias ResolvePushEventsResult = Result<Map<NotificationEventRequest, Result<
interface NotifiableEventResolver { interface NotifiableEventResolver {
suspend fun resolveEvents( suspend fun resolveEvents(
sessionId: SessionId, sessionId: SessionId,
notificationEventRequests: List<NotificationEventRequest> notificationEventRequests: List<PushRequest>
): ResolvePushEventsResult ): ResolvePushEventsResult
} }
@ -96,15 +96,15 @@ class DefaultNotifiableEventResolver(
) : NotifiableEventResolver { ) : NotifiableEventResolver {
override suspend fun resolveEvents( override suspend fun resolveEvents(
sessionId: SessionId, sessionId: SessionId,
notificationEventRequests: List<NotificationEventRequest> notificationEventRequests: List<PushRequest>
): ResolvePushEventsResult { ): ResolvePushEventsResult {
Timber.d("Queueing notifications: $notificationEventRequests") Timber.d("Queueing notifications: $notificationEventRequests")
val client = matrixClientProvider.getOrRestore(sessionId).getOrElse { val client = matrixClientProvider.getOrRestore(sessionId).getOrElse {
return Result.failure(it) return Result.failure(it)
} }
val ids = notificationEventRequests.groupBy { it.roomId } val ids = notificationEventRequests.groupBy { RoomId(it.roomId) }
.mapValues { (_, requests) -> .mapValues { (_, requests) ->
requests.map { it.eventId } requests.map { EventId(it.eventId) }
} }
// TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event // TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event
@ -125,7 +125,7 @@ class DefaultNotifiableEventResolver(
return Result.success( return Result.success(
notificationEventRequests.associate { request -> notificationEventRequests.associate { request ->
val notificationDataResult = notificationDataMap[request.eventId] val notificationDataResult = notificationDataMap[EventId(request.eventId)]
if (notificationDataResult == null) { if (notificationDataResult == null) {
request to Result.failure(NotificationResolverException.UnknownError("No notification data for ${request.roomId} - ${request.eventId}")) request to Result.failure(NotificationResolverException.UnknownError("No notification data for ${request.roomId} - ${request.eventId}"))
} else { } else {

View file

@ -101,7 +101,7 @@ class DefaultNotificationMediaRepo(
} }
} }
private fun MediaSource.cachedFile(): File? = mxcTools.mxcUri2FilePath(url)?.let { private fun MediaSource.cachedFile(): File? = mxcTools.mxcUri2FilePath(safeUrl)?.let {
File("${cacheDir.path}/$CACHE_NOTIFICATION_SUBDIR/$it") File("${cacheDir.path}/$CACHE_NOTIFICATION_SUBDIR/$it")
} }
} }

View file

@ -1,125 +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.notifications
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.push.api.push.NotificationEventRequest
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.push.impl.workmanager.SyncNotificationWorkManagerRequest
import io.element.android.libraries.push.impl.workmanager.SyncNotificationsWorkerDataConverter
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds
interface NotificationResolverQueue {
val results: SharedFlow<Pair<List<NotificationEventRequest>, Map<NotificationEventRequest, Result<ResolvedPushEvent>>>>
suspend fun enqueue(request: NotificationEventRequest)
}
/**
* This class is responsible for periodically batching notification requests and resolving them in a single call,
* so that we can avoid having to resolve each notification individually in the SDK.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultNotificationResolverQueue(
private val notifiableEventResolver: NotifiableEventResolver,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
private val workManagerScheduler: WorkManagerScheduler,
private val featureFlagService: FeatureFlagService,
private val workerDataConverter: SyncNotificationsWorkerDataConverter,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
) : NotificationResolverQueue {
companion object {
private const val BATCH_WINDOW_MS = 250L
}
private val requestQueue = Channel<NotificationEventRequest>(capacity = 100)
private var currentProcessingJob: Job? = null
/**
* A flow that emits pairs of a list of notification event requests and a map of the resolved events.
* The map contains the original request as the key and the resolved event as the value.
*/
override val results = MutableSharedFlow<Pair<List<NotificationEventRequest>, Map<NotificationEventRequest, Result<ResolvedPushEvent>>>>()
/**
* Enqueues a notification event request to be resolved.
* The request will be processed in batches, so it may not be resolved immediately.
*
* @param request The notification event request to enqueue.
*/
override suspend fun enqueue(request: NotificationEventRequest) {
// Cancel previous processing job if it exists, acting as a debounce operation
Timber.d("Cancelling job: $currentProcessingJob")
currentProcessingJob?.cancel()
// Enqueue the request and start a delayed processing job
requestQueue.send(request)
currentProcessingJob = processQueue()
Timber.d("Starting processing job for request: $request")
}
private fun processQueue() = appCoroutineScope.launch(SupervisorJob()) {
delay(BATCH_WINDOW_MS.milliseconds)
// If this job is still active (so this is the latest job), we launch a separate one that won't be cancelled when enqueueing new items
// to process the existing queued items.
appCoroutineScope.launch {
val groupedRequestsById = buildList {
while (!requestQueue.isEmpty) {
requestQueue.receiveCatching().getOrNull()?.let(::add)
}
}.groupBy { it.sessionId }
if (featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) {
for ((sessionId, requests) in groupedRequestsById) {
workManagerScheduler.submit(
SyncNotificationWorkManagerRequest(
sessionId = sessionId,
notificationEventRequests = requests,
workerDataConverter = workerDataConverter,
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
)
)
}
} else {
val sessionIds = groupedRequestsById.keys
for (sessionId in sessionIds) {
val requests = groupedRequestsById[sessionId].orEmpty()
Timber.d("Fetching notifications for $sessionId: $requests. Pending requests: ${!requestQueue.isEmpty}")
// Resolving the events in parallel should improve performance since each session id will query a different Client
launch {
// No need for a Mutex since the SDK already has one internally
val notifications = notifiableEventResolver.resolveEvents(sessionId, requests).getOrNull().orEmpty()
results.emit(requests to notifications)
}
}
}
}
}
}

View file

@ -0,0 +1,243 @@
/*
* 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.notifications
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.push.impl.db.PushRequest
import io.element.android.libraries.push.impl.history.PushHistoryService
import io.element.android.libraries.push.impl.history.onSuccess
import io.element.android.libraries.push.impl.history.onUnableToResolveEvent
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore
import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived
import io.element.android.libraries.push.impl.push.OnRedactedEventReceived
import io.element.android.libraries.push.impl.push.SyncOnNotifiableEvent
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
private const val TAG = "NotifResultProcessor"
interface NotificationResultProcessor {
suspend fun emit(results: Map<PushRequest, Result<ResolvedPushEvent>>)
fun start()
fun stop()
}
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultNotificationResultProcessor(
private val pushHistoryService: PushHistoryService,
private val batteryOptimizationStore: MutableBatteryOptimizationStore,
private val fallbackNotificationFactory: FallbackNotificationFactory,
private val userPushStoreFactory: UserPushStoreFactory,
private val onRedactedEventReceived: OnRedactedEventReceived,
private val onNotifiableEventReceived: OnNotifiableEventReceived,
private val featureFlagService: FeatureFlagService,
private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val notificationChannels: NotificationChannels,
@AppCoroutineScope private val coroutineScope: CoroutineScope,
) : NotificationResultProcessor {
private val resultFlow = MutableSharedFlow<Map<PushRequest, Result<ResolvedPushEvent>>>(extraBufferCapacity = Int.MAX_VALUE)
private var processJob: Job? = null
override suspend fun emit(results: Map<PushRequest, Result<ResolvedPushEvent>>) {
resultFlow.emit(results)
}
override fun start() {
if (processJob?.isActive == true) {
Timber.tag(TAG).w("Is already processing, not starting again")
return
}
processJob = resultFlow
.onEach(::processResults)
.launchIn(coroutineScope)
}
override fun stop() {
if (processJob?.isActive != true) {
Timber.tag(TAG).w("Is not processing, not stopping")
return
}
processJob?.cancel()
processJob = null
}
private suspend fun processResults(results: Map<PushRequest, Result<ResolvedPushEvent>>) {
// TODO what happens with items that weren't reported back?
for ((request, result) in results) {
result.fold(
onSuccess = {
if (it is ResolvedPushEvent.Event && it.notifiableEvent is FallbackNotifiableEvent) {
pushHistoryService.onUnableToResolveEvent(
providerInfo = request.providerInfo,
eventId = EventId(request.eventId),
roomId = RoomId(request.roomId),
sessionId = SessionId(request.sessionId),
reason = it.notifiableEvent.cause.orEmpty(),
)
} else {
pushHistoryService.onSuccess(
providerInfo = request.providerInfo,
eventId = EventId(request.eventId),
roomId = RoomId(request.roomId),
sessionId = SessionId(request.sessionId),
comment = "Push handled successfully",
)
}
},
onFailure = { exception ->
if (exception is NotificationResolverException.EventFilteredOut) {
pushHistoryService.onSuccess(
providerInfo = request.providerInfo,
eventId = EventId(request.eventId),
roomId = RoomId(request.roomId),
sessionId = SessionId(request.sessionId),
comment = "Push handled successfully but notification was filtered out",
)
} else if (exception is NotificationResolverException.EventRedacted) {
pushHistoryService.onSuccess(
providerInfo = request.providerInfo,
eventId = EventId(request.eventId),
roomId = RoomId(request.roomId),
sessionId = SessionId(request.sessionId),
comment = "Push handled successfully but event has been redacted",
)
} else {
val reason = when (exception) {
is NotificationResolverException.EventNotFound -> "Event not found"
else -> "Unknown error: ${exception.message}"
}
pushHistoryService.onUnableToResolveEvent(
providerInfo = request.providerInfo,
eventId = EventId(request.eventId),
roomId = RoomId(request.roomId),
sessionId = SessionId(request.sessionId),
reason = "$reason - Showing fallback notification",
)
batteryOptimizationStore.showBatteryOptimizationBanner()
}
}
)
}
val events = mutableListOf<NotifiableEvent>()
val redactions = mutableListOf<ResolvedPushEvent.Redaction>()
@Suppress("LoopWithTooManyJumpStatements")
for ((request, result) in results) {
val event = result.recover { exception ->
// If the event could not be resolved, we create a fallback notification
when (exception) {
is NotificationResolverException.EventFilteredOut -> {
// Do nothing, we don't want to show a notification for filtered out events
null
}
is NotificationResolverException.EventRedacted -> {
// Do nothing, we don't want to show a notification for redacted events
null
}
else -> {
Timber.tag(TAG).e(exception, "Failed to resolve push event")
ResolvedPushEvent.Event(
fallbackNotificationFactory.create(
sessionId = SessionId(request.sessionId),
roomId = RoomId(request.roomId),
eventId = EventId(request.eventId),
cause = exception.message,
)
)
}
}
}.getOrNull() ?: continue
val userPushStore = userPushStoreFactory.getOrCreate(event.sessionId)
val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first()
// If notifications are disabled for this session and device, we don't want to show the notification
// But if it's a ringing call, we want to show it anyway
val isRingingCall = (event as? ResolvedPushEvent.Event)?.notifiableEvent is NotifiableRingingCallEvent
if (!areNotificationsEnabled && !isRingingCall) continue
// We categorise each result into either a NotifiableEvent or a Redaction
when (event) {
is ResolvedPushEvent.Event -> {
events.add(event.notifiableEvent)
}
is ResolvedPushEvent.Redaction -> {
redactions.add(event)
}
}
}
// Process redactions of messages in background to not block operations with higher priority
if (redactions.isNotEmpty()) {
coroutineScope.launch { onRedactedEventReceived.onRedactedEventsReceived(redactions) }
}
// Find and process ringing call notifications separately
val (ringingCallEvents, nonRingingCallEvents) = events.partition { it is NotifiableRingingCallEvent }
for (ringingCallEvent in ringingCallEvents) {
Timber.tag(TAG).d("Ringing call event: $ringingCallEvent")
handleRingingCallEvent(ringingCallEvent as NotifiableRingingCallEvent)
}
// Finally, process other notifications (messages, invites, generic notifications, etc.)
if (nonRingingCallEvents.isNotEmpty()) {
onNotifiableEventReceived.onNotifiableEventsReceived(nonRingingCallEvents)
}
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) {
syncOnNotifiableEvent(results.keys.toList())
}
}
private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) {
Timber.i("## handleInternal() : Incoming call.")
elementCallEntryPoint.handleIncomingCall(
callType = CallType.RoomCall(
notifiableEvent.sessionId,
notifiableEvent.roomId,
isAudioCall = notifiableEvent.callIntent == CallIntent.AUDIO
),
eventId = notifiableEvent.eventId,
senderId = notifiableEvent.senderId,
roomName = notifiableEvent.roomName,
senderName = notifiableEvent.senderDisambiguatedDisplayName,
avatarUrl = notifiableEvent.roomAvatarUrl,
timestamp = notifiableEvent.timestamp,
expirationTimestamp = notifiableEvent.expirationTimestamp,
notificationChannelId = notificationChannels.getChannelForIncomingCall(ring = true),
textContent = notifiableEvent.description,
)
}
}

View file

@ -11,43 +11,28 @@ package io.element.android.libraries.push.impl.push
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.push.impl.db.PushRequest
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.push.api.push.NotificationEventRequest
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
import io.element.android.libraries.push.impl.history.PushHistoryService import io.element.android.libraries.push.impl.history.PushHistoryService
import io.element.android.libraries.push.impl.history.onDiagnosticPush import io.element.android.libraries.push.impl.history.onDiagnosticPush
import io.element.android.libraries.push.impl.history.onInvalidPushReceived import io.element.android.libraries.push.impl.history.onInvalidPushReceived
import io.element.android.libraries.push.impl.history.onSuccess
import io.element.android.libraries.push.impl.history.onUnableToResolveEvent
import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession
import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory import io.element.android.libraries.push.impl.notifications.NotificationResultProcessor
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.push.impl.test.DefaultTestPush 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.troubleshoot.DiagnosticPushHandler
import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder
import io.element.android.libraries.pushproviders.api.PushData import io.element.android.libraries.pushproviders.api.PushData
import io.element.android.libraries.pushproviders.api.PushHandler import io.element.android.libraries.pushproviders.api.PushHandler
import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import io.element.android.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.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag) private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag)
@ -55,173 +40,20 @@ private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag)
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultPushHandler( class DefaultPushHandler(
private val onNotifiableEventReceived: OnNotifiableEventReceived,
private val onRedactedEventReceived: OnRedactedEventReceived,
private val incrementPushDataStore: IncrementPushDataStore, private val incrementPushDataStore: IncrementPushDataStore,
private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
private val userPushStoreFactory: UserPushStoreFactory,
private val pushClientSecret: PushClientSecret, private val pushClientSecret: PushClientSecret,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val diagnosticPushHandler: DiagnosticPushHandler, private val diagnosticPushHandler: DiagnosticPushHandler,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val notificationChannels: NotificationChannels,
private val pushHistoryService: PushHistoryService, private val pushHistoryService: PushHistoryService,
private val resolverQueue: NotificationResolverQueue, private val userPushStoreFactory: UserPushStoreFactory,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
private val fallbackNotificationFactory: FallbackNotificationFactory,
private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
private val featureFlagService: FeatureFlagService,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val systemClock: SystemClock,
private val workManagerScheduler: WorkManagerScheduler,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
resultProcessor: NotificationResultProcessor,
) : PushHandler { ) : PushHandler {
init { init {
processPushEventResults() resultProcessor.start()
}
/**
* Process the push notification event results emitted by the [resolverQueue].
*/
private fun processPushEventResults() {
resolverQueue.results
.map { (requests, resolvedEvents) ->
for (request in requests) {
// Log the result of the push notification event
val result = resolvedEvents[request]
if (result == null) {
pushHistoryService.onUnableToResolveEvent(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
reason = "Push not handled: no result found for request",
)
} else {
result.fold(
onSuccess = {
if (it is ResolvedPushEvent.Event && it.notifiableEvent is FallbackNotifiableEvent) {
pushHistoryService.onUnableToResolveEvent(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
reason = it.notifiableEvent.cause.orEmpty(),
)
} else {
pushHistoryService.onSuccess(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
comment = "Push handled successfully",
)
}
},
onFailure = { exception ->
if (exception is NotificationResolverException.EventFilteredOut) {
pushHistoryService.onSuccess(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
comment = "Push handled successfully but notification was filtered out",
)
} else if (exception is NotificationResolverException.EventRedacted) {
pushHistoryService.onSuccess(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
comment = "Push handled successfully but event has been redacted",
)
} else {
val reason = when (exception) {
is NotificationResolverException.EventNotFound -> "Event not found"
else -> "Unknown error: ${exception.message}"
}
pushHistoryService.onUnableToResolveEvent(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
reason = "$reason - Showing fallback notification",
)
mutableBatteryOptimizationStore.showBatteryOptimizationBanner()
}
}
)
}
}
val events = mutableListOf<NotifiableEvent>()
val redactions = mutableListOf<ResolvedPushEvent.Redaction>()
@Suppress("LoopWithTooManyJumpStatements")
for ((request, result) in resolvedEvents) {
val event = result.recover { exception ->
// If the event could not be resolved, we create a fallback notification
when (exception) {
is NotificationResolverException.EventFilteredOut -> {
// Do nothing, we don't want to show a notification for filtered out events
null
}
is NotificationResolverException.EventRedacted -> {
// Do nothing, we don't want to show a notification for redacted events
null
}
else -> {
Timber.tag(loggerTag.value).e(exception, "Failed to resolve push event")
ResolvedPushEvent.Event(
fallbackNotificationFactory.create(
sessionId = request.sessionId,
roomId = request.roomId,
eventId = request.eventId,
cause = exception.message,
)
)
}
}
}.getOrNull() ?: continue
val userPushStore = userPushStoreFactory.getOrCreate(event.sessionId)
val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first()
// If notifications are disabled for this session and device, we don't want to show the notification
// But if it's a ringing call, we want to show it anyway
val isRingingCall = (event as? ResolvedPushEvent.Event)?.notifiableEvent is NotifiableRingingCallEvent
if (!areNotificationsEnabled && !isRingingCall) continue
// We categorise each result into either a NotifiableEvent or a Redaction
when (event) {
is ResolvedPushEvent.Event -> {
events.add(event.notifiableEvent)
}
is ResolvedPushEvent.Redaction -> {
redactions.add(event)
}
}
}
// Process redactions of messages in background to not block operations with higher priority
if (redactions.isNotEmpty()) {
appCoroutineScope.launch { onRedactedEventReceived.onRedactedEventsReceived(redactions) }
}
// Find and process ringing call notifications separately
val (ringingCallEvents, nonRingingCallEvents) = events.partition { it is NotifiableRingingCallEvent }
for (ringingCallEvent in ringingCallEvents) {
Timber.tag(loggerTag.value).d("Ringing call event: $ringingCallEvent")
handleRingingCallEvent(ringingCallEvent as NotifiableRingingCallEvent)
}
// Finally, process other notifications (messages, invites, generic notifications, etc.)
if (nonRingingCallEvents.isNotEmpty()) {
onNotifiableEventReceived.onNotifiableEventsReceived(nonRingingCallEvents)
}
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) {
syncOnNotifiableEvent(requests)
}
}
.launchIn(appCoroutineScope)
} }
/** /**
@ -234,9 +66,7 @@ class DefaultPushHandler(
// Start measuring how long it takes to display a notification from when the push is received // Start measuring how long it takes to display a notification from when the push is received
Timber.d("Calculating push-to-notification for event ${pushData.eventId}") Timber.d("Calculating push-to-notification for event ${pushData.eventId}")
val parent = analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(pushData.eventId.value)) val parent = analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(pushData.eventId.value))
if (featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) { analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(pushData.eventId.value), parent)
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(pushData.eventId.value), parent)
}
Timber.tag(loggerTag.value).d("## handling pushData: ${pushData.roomId}/${pushData.eventId}") Timber.tag(loggerTag.value).d("## handling pushData: ${pushData.roomId}/${pushData.eventId}")
if (buildMeta.lowPrivacyLoggingEnabled) { if (buildMeta.lowPrivacyLoggingEnabled) {
@ -283,38 +113,56 @@ class DefaultPushHandler(
return return
} }
appCoroutineScope.launch { val areNotificationsEnabled = userPushStoreFactory.getOrCreate(userId).getNotificationEnabledForDevice().first()
val notificationEventRequest = NotificationEventRequest( if (!areNotificationsEnabled) {
sessionId = userId, Timber.w("Push notification received when push notifications are disabled.")
roomId = pushData.roomId, return
eventId = pushData.eventId, }
providerInfo = providerInfo,
val pushRequest = PushRequest(
pushDate = systemClock.epochMillis(),
providerInfo = providerInfo,
eventId = pushData.eventId.value,
roomId = pushData.roomId.value,
sessionId = userId.value,
status = PushRequestStatus.PENDING.value,
retries = 0L,
)
Timber.d("Queueing notification: $pushRequest")
pushHistoryService.insertOrUpdatePushRequest(pushRequest)
if (!workManagerScheduler.hasPendingWork(userId, WorkManagerRequestType.NOTIFICATION_SYNC)) {
Timber.d("No pending worker for push notifications found")
workManagerScheduler.submit(
SyncPendingNotificationsRequestBuilder(
sessionId = userId,
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
)
) )
Timber.d("Queueing notification: $notificationEventRequest")
resolverQueue.enqueue(notificationEventRequest)
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
} }
} }
}
private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) {
Timber.i("## handleInternal() : Incoming call.") /**
elementCallEntryPoint.handleIncomingCall( * Represents the status of a [PushRequest].
callType = CallType.RoomCall( */
notifiableEvent.sessionId, enum class PushRequestStatus(val value: Long) {
notifiableEvent.roomId, /**
isAudioCall = notifiableEvent.callIntent == CallIntent.AUDIO * Either it was enqueued, and we never tried to fetch it, or it failed with a recoverable error.
), */
eventId = notifiableEvent.eventId, PENDING(0),
senderId = notifiableEvent.senderId,
roomName = notifiableEvent.roomName, /**
senderName = notifiableEvent.senderDisambiguatedDisplayName, * The event for the [PushRequest] was fetched successfully.
avatarUrl = notifiableEvent.roomAvatarUrl, */
timestamp = notifiableEvent.timestamp, SUCCESS(1),
expirationTimestamp = notifiableEvent.expirationTimestamp,
notificationChannelId = notificationChannels.getChannelForIncomingCall(ring = true), /**
textContent = notifiableEvent.description, * Fetching the event for the [PushRequest] failed with an unrecoverable error, and it won't be retried.
) */
} FAILED(2),
} }

View file

@ -14,8 +14,9 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.push.api.push.NotificationEventRequest import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.impl.db.PushRequest
import io.element.android.services.appnavstate.api.AppForegroundStateService import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -29,7 +30,7 @@ class DefaultSyncOnNotifiableEvent(
private val appForegroundStateService: AppForegroundStateService, private val appForegroundStateService: AppForegroundStateService,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
) : SyncOnNotifiableEvent { ) : SyncOnNotifiableEvent {
override suspend operator fun invoke(requests: List<NotificationEventRequest>) = withContext(dispatchers.io) { override suspend operator fun invoke(requests: List<PushRequest>) = withContext(dispatchers.io) {
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush)) { if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush)) {
return@withContext return@withContext
} }
@ -41,8 +42,8 @@ class DefaultSyncOnNotifiableEvent(
Timber.d("Starting opportunistic room list sync | In foreground: ${appForegroundStateService.isInForeground.value}") Timber.d("Starting opportunistic room list sync | In foreground: ${appForegroundStateService.isInForeground.value}")
for ((sessionId, events) in eventsBySession) { for ((sessionId, events) in eventsBySession) {
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: continue val client = matrixClientProvider.getOrRestore(SessionId(sessionId)).getOrNull() ?: continue
val roomIds = events.map { it.roomId }.distinct() val roomIds = events.map { RoomId(it.roomId) }.distinct()
client.roomListService.subscribeToVisibleRooms(roomIds) client.roomListService.subscribeToVisibleRooms(roomIds)

View file

@ -0,0 +1,14 @@
/*
* 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.push
import io.element.android.libraries.push.impl.db.PushRequest
fun interface SyncOnNotifiableEvent {
suspend operator fun invoke(requests: List<PushRequest>)
}

View file

@ -8,8 +8,8 @@
package io.element.android.libraries.push.impl.troubleshoot package io.element.android.libraries.push.impl.troubleshoot
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet import dev.zacsweers.metro.ContributesIntoSet
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.R
import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest
@ -19,7 +19,7 @@ import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ContributesIntoSet(AppScope::class) @ContributesIntoSet(SessionScope::class)
class PushProvidersTest( class PushProvidersTest(
pushProviders: Set<@JvmSuppressWildcards PushProvider>, pushProviders: Set<@JvmSuppressWildcards PushProvider>,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,

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