Merge branch 'develop' into valere/rtc/voice_call
This commit is contained in:
commit
04a9c677fb
187 changed files with 2612 additions and 2314 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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?
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
features/share/test/build.gradle.kts
Normal file
21
features/share/test/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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>)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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?,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>)
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue