Browse Source

Merge branch 'develop' into feature/fga/message_queuing

pull/3011/head
ganfra 3 months ago
parent
commit
b927daffe7
  1. 4
      .github/workflows/release.yml
  2. 27
      CHANGES.md
  3. 2
      README.md
  4. 24
      app/src/main/AndroidManifest.xml
  5. 1
      app/src/main/res/xml/locales_config.xml
  6. 8
      appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt
  7. 2
      appnav/build.gradle.kts
  8. 31
      appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
  9. 13
      appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
  10. 5
      appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
  11. 6
      appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt
  12. 24
      appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt
  13. 22
      appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
  14. 2
      build.gradle.kts
  15. 1
      changelog.d/2809.bugfix
  16. 1
      changelog.d/2893.misc
  17. 1
      changelog.d/2894.feature
  18. 1
      changelog.d/2896.bugfix
  19. 1
      changelog.d/2898.bugfix
  20. 1
      changelog.d/2912.misc
  21. 1
      changelog.d/2917.bugfix
  22. 3
      changelog.d/2924.misc
  23. 1
      changelog.d/2930.misc
  24. 1
      changelog.d/2932.misc
  25. 1
      changelog.d/2953.misc
  26. 1
      changelog.d/2959.bugfix
  27. 1
      changelog.d/2969.misc
  28. 2
      fastlane/metadata/android/en-US/changelogs/40004140.txt
  29. 7
      features/analytics/api/src/main/res/values-et/translations.xml
  30. 10
      features/analytics/impl/src/main/res/values-et/translations.xml
  31. 31
      features/call/api/build.gradle.kts
  32. 4
      features/call/api/src/main/kotlin/io/element/android/features/call/api/CallType.kt
  33. 53
      features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt
  34. 16
      features/call/impl/build.gradle.kts
  35. 24
      features/call/impl/src/main/AndroidManifest.xml
  36. 69
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt
  37. 2
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/data/WidgetMessage.kt
  38. 8
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/di/CallBindings.kt
  39. 37
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/CallNotificationData.kt
  40. 130
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt
  41. 37
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt
  42. 34
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/services/CallForegroundService.kt
  43. 7
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt
  44. 27
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt
  45. 2
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt
  46. 6
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
  47. 48
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
  48. 96
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt
  49. 192
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt
  50. 206
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
  51. 2
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallIntentDataParser.kt
  52. 2
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallWidgetProvider.kt
  53. 2
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt
  54. 42
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/IntentProvider.kt
  55. 4
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt
  56. 2
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WidgetMessageInterceptor.kt
  57. 4
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WidgetMessageSerializer.kt
  58. 0
      features/call/impl/src/main/res/values-be/translations.xml
  59. 0
      features/call/impl/src/main/res/values-cs/translations.xml
  60. 0
      features/call/impl/src/main/res/values-de/translations.xml
  61. 0
      features/call/impl/src/main/res/values-es/translations.xml
  62. 0
      features/call/impl/src/main/res/values-fr/translations.xml
  63. 0
      features/call/impl/src/main/res/values-hu/translations.xml
  64. 0
      features/call/impl/src/main/res/values-in/translations.xml
  65. 0
      features/call/impl/src/main/res/values-it/translations.xml
  66. 0
      features/call/impl/src/main/res/values-ka/translations.xml
  67. 0
      features/call/impl/src/main/res/values-pt/translations.xml
  68. 0
      features/call/impl/src/main/res/values-ro/translations.xml
  69. 0
      features/call/impl/src/main/res/values-ru/translations.xml
  70. 0
      features/call/impl/src/main/res/values-sk/translations.xml
  71. 0
      features/call/impl/src/main/res/values-sv/translations.xml
  72. 0
      features/call/impl/src/main/res/values-uk/translations.xml
  73. 0
      features/call/impl/src/main/res/values-zh-rTW/translations.xml
  74. 0
      features/call/impl/src/main/res/values-zh/translations.xml
  75. 0
      features/call/impl/src/main/res/values/do_not_translate.xml
  76. 1
      features/call/impl/src/main/res/values/localazy.xml
  77. 77
      features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt
  78. 2
      features/call/impl/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt
  79. 97
      features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt
  80. 10
      features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
  81. 2
      features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt
  82. 1
      features/call/impl/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt
  83. 203
      features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
  84. 1
      features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt
  85. 53
      features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt
  86. 1
      features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt
  87. 1
      features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt
  88. 6
      features/call/src/main/res/values-et/translations.xml
  89. 34
      features/call/test/build.gradle.kts
  90. 52
      features/call/test/src/main/kotlin/io/element/android/features/call/test/CallNotificationData.kt
  91. 44
      features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt
  92. 9
      features/createroom/impl/src/main/res/values-et/translations.xml
  93. 11
      features/ftue/impl/src/main/res/values-et/translations.xml
  94. 32
      features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt
  95. 2
      features/leaveroom/api/src/main/res/values-be/translations.xml
  96. 7
      features/leaveroom/api/src/main/res/values-et/translations.xml
  97. 37
      features/lockscreen/impl/src/main/res/values-et/translations.xml
  98. 2
      features/login/impl/src/main/res/values-cs/translations.xml
  99. 26
      features/login/impl/src/main/res/values-et/translations.xml
  100. 13
      features/login/impl/src/main/res/values-pt/translations.xml
  101. Some files were not shown because too many files have changed in this diff Show More

4
.github/workflows/release.yml

@ -15,7 +15,7 @@ jobs: @@ -15,7 +15,7 @@ jobs:
name: Create App Bundle (Gplay)
runs-on: ubuntu-latest
concurrency:
group: ${{ github.ref == 'refs/head/main' && format('build-release-main-gplay-{0}', github.sha) }}
group: ${{ format('build-release-main-gplay-{0}', github.sha) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v4
@ -43,7 +43,7 @@ jobs: @@ -43,7 +43,7 @@ jobs:
name: Create APKs (FDroid)
runs-on: ubuntu-latest
concurrency:
group: ${{ github.ref == 'refs/head/main' && format('build-release-main-fdroid-{0}', github.sha) }}
group: ${{ format('build-release-main-fdroid-{0}', github.sha) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v4

27
CHANGES.md

@ -1,3 +1,30 @@ @@ -1,3 +1,30 @@
Changes in Element X v0.4.14 (2024-06-07)
=========================================
Features ✨
----------
- Add support for incoming share (text or files) from other apps ([#1980](https://github.com/element-hq/element-x-android/issues/1980))
Bugfixes 🐛
----------
- Render selected/deselected room list filters on top ([#2809](https://github.com/element-hq/element-x-android/issues/2809))
- Set auto captilization, multiline and autocompletion flags for the markdown EditText. ([#2896](https://github.com/element-hq/element-x-android/issues/2896))
- Restore Markdown text input contents when returning to the room screen. ([#2898](https://github.com/element-hq/element-x-android/issues/2898))
- Fixed sending rich content from android keyboards on the markdown text input ([#2917](https://github.com/element-hq/element-x-android/issues/2917))
- Fix crash when restoring the selection values in the plain text editor. ([#2959](https://github.com/element-hq/element-x-android/issues/2959))
Other changes
-------------
- BugReporting | Add public device keys to rageshakes ([#2893](https://github.com/element-hq/element-x-android/issues/2893))
- Move push provider setting to the "Notifications" screen and display it only when several push provider are available. ([#2912](https://github.com/element-hq/element-x-android/issues/2912))
- Simplify notifications by removing the custom persistence layer.
- Bump minSdk to 24 (Android 7). ([#2924](https://github.com/element-hq/element-x-android/issues/2924))
- Add a feature flag ShowBlockedUsersDetails, disabled by default to render display name and avatar of blocked users in the blocked users list. ([#2930](https://github.com/element-hq/element-x-android/issues/2930))
- Be more specific with the widget permissions ([#2932](https://github.com/element-hq/element-x-android/issues/2932))
- Analytics | Add support for SuperProperties ([#2953](https://github.com/element-hq/element-x-android/issues/2953))
- Track when the user starts a room call and when they enable formatting options on the message composer ([#2969](https://github.com/element-hq/element-x-android/issues/2969))
Changes in Element X v0.4.13 (2024-05-22)
=========================================

2
README.md

@ -10,7 +10,7 @@ @@ -10,7 +10,7 @@
Element X Android is a [Matrix](https://matrix.org/) Android Client provided by [element.io](https://element.io/). This app is currently in a pre-alpha release stage with only basic functionalities.
The application is a total rewrite of [Element-Android](https://github.com/element-hq/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 6+. The UI layer is written using [Jetpack Compose](https://developer.android.com/jetpack/compose), and the navigation is managed using [Appyx](https://github.com/bumble-tech/appyx).
The application is a total rewrite of [Element-Android](https://github.com/element-hq/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 7+. The UI layer is written using [Jetpack Compose](https://developer.android.com/jetpack/compose), and the navigation is managed using [Appyx](https://github.com/bumble-tech/appyx).
Learn more about why we are building Element X in our blog post: [https://element.io/blog/element-x-experience-the-future-of-element/](https://element.io/blog/element-x-experience-the-future-of-element/).

24
app/src/main/AndroidManifest.xml

@ -122,6 +122,30 @@ @@ -122,6 +122,30 @@
</intent-filter>
</activity>
<!-- Using an activity-alias for incoming share intent, in order
to be able to disable the feature programmatically -->
<activity-alias
android:name=".ShareActivity"
android:exported="true"
android:targetActivity=".MainActivity">
<!-- Incoming share simple -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="*/*" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.OPENABLE" />
</intent-filter>
<!-- Incoming share multiple -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<data android:mimeType="*/*" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.OPENABLE" />
</intent-filter>
</activity-alias>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"

1
app/src/main/res/xml/locales_config.xml

@ -6,6 +6,7 @@ @@ -6,6 +6,7 @@
<locale android:name="de"/>
<locale android:name="en"/>
<locale android:name="es"/>
<locale android:name="et"/>
<locale android:name="fr"/>
<locale android:name="hu"/>
<locale android:name="in"/>

8
appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt

@ -17,5 +17,13 @@ @@ -17,5 +17,13 @@
package io.element.android.appconfig
object ElementCallConfig {
/**
* The default base URL for the Element Call service.
*/
const val DEFAULT_BASE_URL = "https://call.element.io"
/**
* The default duration of a ringing call in seconds before it's automatically dismissed.
*/
const val RINGING_CALL_DURATION_SECONDS = 15
}

2
appnav/build.gradle.kts

@ -52,6 +52,7 @@ dependencies { @@ -52,6 +52,7 @@ dependencies {
implementation(libs.coil)
implementation(projects.features.ftue.api)
implementation(projects.features.share.api)
implementation(projects.features.viewfolder.api)
implementation(projects.services.apperror.impl)
@ -71,6 +72,7 @@ dependencies { @@ -71,6 +72,7 @@ dependencies {
testImplementation(projects.tests.testutils)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl)
testImplementation(projects.features.share.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.analytics.test)
testImplementation(libs.test.appyx.junit)

31
appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
package io.element.android.appnav
import android.content.Intent
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
@ -55,6 +56,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription @@ -55,6 +56,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
@ -99,6 +101,7 @@ class LoggedInFlowNode @AssistedInject constructor( @@ -99,6 +101,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val networkMonitor: NetworkMonitor,
private val ftueService: FtueService,
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
private val shareEntryPoint: ShareEntryPoint,
private val matrixClient: MatrixClient,
private val sendingQueue: SendingQueue,
snackbarDispatcher: SnackbarDispatcher,
@ -226,6 +229,9 @@ class LoggedInFlowNode @AssistedInject constructor( @@ -226,6 +229,9 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize
data object RoomDirectorySearch : NavTarget
@Parcelize
data class IncomingShare(val intent: Intent) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -382,6 +388,20 @@ class LoggedInFlowNode @AssistedInject constructor( @@ -382,6 +388,20 @@ class LoggedInFlowNode @AssistedInject constructor(
})
.build()
}
is NavTarget.IncomingShare -> {
shareEntryPoint.nodeBuilder(this, buildContext)
.callback(object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
navigateUp()
if (roomIds.size == 1) {
val targetRoomId = roomIds.first()
backstack.push(NavTarget.Room(targetRoomId.toRoomIdOrAlias()))
}
}
})
.params(ShareEntryPoint.Params(intent = navTarget.intent))
.build()
}
}
}
@ -421,6 +441,17 @@ class LoggedInFlowNode @AssistedInject constructor( @@ -421,6 +441,17 @@ class LoggedInFlowNode @AssistedInject constructor(
}
}
internal suspend fun attachIncomingShare(intent: Intent) {
waitForNavTargetAttached { navTarget ->
navTarget is NavTarget.RoomList
}
attachChild<Node> {
backstack.push(
NavTarget.IncomingShare(intent)
)
}
}
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier) {

13
appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt

@ -283,6 +283,19 @@ class RootFlowNode @AssistedInject constructor( @@ -283,6 +283,19 @@ class RootFlowNode @AssistedInject constructor(
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)
is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.intent)
}
}
private suspend fun onIncomingShare(intent: Intent) {
// Is there a session already?
val latestSessionId = authenticationService.getLatestSessionId()
if (latestSessionId == null) {
// No session, open login
switchToNotLoggedInFlow()
} else {
attachSession(latestSessionId)
.attachIncomingShare(intent)
}
}

5
appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt

@ -30,6 +30,7 @@ sealed interface ResolvedIntent { @@ -30,6 +30,7 @@ sealed interface ResolvedIntent {
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
data class IncomingShare(val intent: Intent) : ResolvedIntent
}
class IntentResolver @Inject constructor(
@ -56,6 +57,10 @@ class IntentResolver @Inject constructor( @@ -56,6 +57,10 @@ class IntentResolver @Inject constructor(
?.takeIf { it !is PermalinkData.FallbackLink }
if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData)
if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) {
return ResolvedIntent.IncomingShare(intent)
}
// Unknown intent
Timber.w("Unknown intent")
return null

6
appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt

@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue @@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue
import im.vector.app.features.analytics.plan.SuperProperties
import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter
import io.element.android.features.share.api.ShareService
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.SdkMetadata
import io.element.android.services.analytics.api.AnalyticsService
@ -34,6 +35,7 @@ class RootPresenter @Inject constructor( @@ -34,6 +35,7 @@ class RootPresenter @Inject constructor(
private val rageshakeDetectionPresenter: RageshakeDetectionPresenter,
private val appErrorStateService: AppErrorStateService,
private val analyticsService: AnalyticsService,
private val shareService: ShareService,
private val sdkMetadata: SdkMetadata,
) : Presenter<RootState> {
@Composable
@ -52,6 +54,10 @@ class RootPresenter @Inject constructor( @@ -52,6 +54,10 @@ class RootPresenter @Inject constructor(
)
}
LaunchedEffect(Unit) {
shareService.observeFeatureFlag(this)
}
return RootState(
rageshakeDetectionState = rageshakeDetectionState,
crashDetectionState = crashDetectionState,

24
appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt

@ -28,6 +28,8 @@ import io.element.android.features.rageshake.test.crash.FakeCrashDataStore @@ -28,6 +28,8 @@ import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
import io.element.android.features.share.api.ShareService
import io.element.android.features.share.test.FakeShareService
import io.element.android.libraries.matrix.test.FakeSdkMetadata
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
@ -35,6 +37,8 @@ import io.element.android.services.apperror.api.AppErrorState @@ -35,6 +37,8 @@ import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.AppErrorStateService
import io.element.android.services.apperror.impl.DefaultAppErrorStateService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ -55,6 +59,22 @@ class RootPresenterTest { @@ -55,6 +59,22 @@ class RootPresenterTest {
}
}
@Test
fun `present - check that share service is invoked`() = runTest {
val lambda = lambdaRecorder<CoroutineScope, Unit> { _ -> }
val presenter = createRootPresenter(
shareService = FakeShareService {
lambda(it)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(2)
lambda.assertions().isCalledOnce()
}
}
@Test
fun `present - passes app error state`() = runTest {
val presenter = createRootPresenter(
@ -79,7 +99,8 @@ class RootPresenterTest { @@ -79,7 +99,8 @@ class RootPresenterTest {
}
private fun createRootPresenter(
appErrorService: AppErrorStateService = DefaultAppErrorStateService()
appErrorService: AppErrorStateService = DefaultAppErrorStateService(),
shareService: ShareService = FakeShareService {},
): RootPresenter {
val crashDataStore = FakeCrashDataStore()
val rageshakeDataStore = FakeRageshakeDataStore()
@ -102,6 +123,7 @@ class RootPresenterTest { @@ -102,6 +123,7 @@ class RootPresenterTest {
rageshakeDetectionPresenter = rageshakeDetectionPresenter,
appErrorStateService = appErrorService,
analyticsService = FakeAnalyticsService(),
shareService = shareService,
sdkMetadata = FakeSdkMetadata("sha")
)
}

22
appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt

@ -209,13 +209,33 @@ class IntentResolverTest { @@ -209,13 +209,33 @@ class IntentResolverTest {
permalinkParserResult = { permalinkData }
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_SEND
action = Intent.ACTION_BATTERY_LOW
data = "https://matrix.to/invalid".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isNull()
}
@Test
fun `test incoming share simple`() {
val sut = createIntentResolver()
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_SEND
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent))
}
@Test
fun `test incoming share multiple`() {
val sut = createIntentResolver()
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_SEND_MULTIPLE
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent))
}
@Test
fun `test resolve invalid`() {
val sut = createIntentResolver(

2
build.gradle.kts

@ -60,7 +60,7 @@ allprojects { @@ -60,7 +60,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.3")
detektPlugins("io.nlopez.compose.rules:detekt:0.4.4")
}
// KtLint

1
changelog.d/2809.bugfix

@ -1 +0,0 @@ @@ -1 +0,0 @@
Render selected/deselected room list filters on top

1
changelog.d/2893.misc

@ -1 +0,0 @@ @@ -1 +0,0 @@
BugReporting | Add public device keys to rageshakes

1
changelog.d/2894.feature

@ -0,0 +1 @@ @@ -0,0 +1 @@
Ringing call notifications and full screen ringing screen for DMs when the device is locked.

1
changelog.d/2896.bugfix

@ -1 +0,0 @@ @@ -1 +0,0 @@
Set auto captilization, multiline and autocompletion flags for the markdown EditText.

1
changelog.d/2898.bugfix

@ -1 +0,0 @@ @@ -1 +0,0 @@
Restoree Markdown text input contents when returning to the room screen.

1
changelog.d/2912.misc

@ -1 +0,0 @@ @@ -1 +0,0 @@
Move push provider setting to the "Notifications" screen and display it only when several push provider are available.

1
changelog.d/2917.bugfix

@ -1 +0,0 @@ @@ -1 +0,0 @@
Fixed sending rich content from android keyboards on the markdown text input

3
changelog.d/2924.misc

@ -1,3 +0,0 @@ @@ -1,3 +0,0 @@
Simplify notifications by removing the custom persistence layer.
Bump minSdk to 24 (Android 7).

1
changelog.d/2930.misc

@ -1 +0,0 @@ @@ -1 +0,0 @@
Add a feature flag ShowBlockedUsersDetails, disabled by default to render display name and avatar of blocked users in the blocked users list.

1
changelog.d/2932.misc

@ -1 +0,0 @@ @@ -1 +0,0 @@
Be more specific with the widget permissions

1
changelog.d/2953.misc

@ -1 +0,0 @@ @@ -1 +0,0 @@
Analytics | Add support for SuperProperties

1
changelog.d/2959.bugfix

@ -1 +0,0 @@ @@ -1 +0,0 @@
Fix crash when restoring the selection values in the plain text editor.

1
changelog.d/2969.misc

@ -1 +0,0 @@ @@ -1 +0,0 @@
Track when the user starts a room call and when they enable formatting options on the message composer

2
fastlane/metadata/android/en-US/changelogs/40004140.txt

@ -0,0 +1,2 @@ @@ -0,0 +1,2 @@
Main changes in this version: Add support for incoming share (text or files) from other apps. Bug fixes.
Full changelog: https://github.com/element-hq/element-x-android/releases

7
features/analytics/api/src/main/res/values-et/translations.xml

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_help_us_improve">"Võimalike rakenduse vigade leidmiseks palun jaga anonüümset kasutusteavet."</string>
<string name="screen_analytics_settings_read_terms">"Sa võid lugeda meie kasutustingimusi %1$s"</string>
<string name="screen_analytics_settings_read_terms_content_link">"siin"</string>
<string name="screen_analytics_settings_share_data">"Jaga andmeid rakenduse kasutuse kohta"</string>
</resources>

10
features/analytics/impl/src/main/res/values-et/translations.xml

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"Me ei salvesta ega profileeri sinu isiklikke andmeid"</string>
<string name="screen_analytics_prompt_help_us_improve">"Võimalike rakenduse vigade leidmiseks palun jaga anonüümset kasutusteavet."</string>
<string name="screen_analytics_prompt_read_terms">"Sa võid lugeda meie kasutustingimusi %1$s"</string>
<string name="screen_analytics_prompt_read_terms_content_link">"siin"</string>
<string name="screen_analytics_prompt_settings">"Selle valiku saad igal ajal välja lülitada"</string>
<string name="screen_analytics_prompt_third_party_sharing">"Me ei jaga andmeid kolmandate osapooltega"</string>
<string name="screen_analytics_prompt_title">"Aita parandada %1$s rakendust"</string>
</resources>

31
features/call/api/build.gradle.kts

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.call.api"
}
dependencies {
implementation(projects.anvilannotations)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
}

4
features/call/src/main/kotlin/io/element/android/features/call/CallType.kt → features/call/api/src/main/kotlin/io/element/android/features/call/api/CallType.kt

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call
package io.element.android.features.call.api
import android.os.Parcelable
import io.element.android.libraries.architecture.NodeInputs

53
features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt

@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.api
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
/**
* Entry point for the call feature.
*/
interface ElementCallEntryPoint {
/**
* Start a call of the given type.
* @param callType The type of call to start.
*/
fun startCall(callType: CallType)
/**
* Handle an incoming call.
* @param callType The type of call.
* @param eventId The event id of the event that started the call.
* @param senderId The user id of the sender of the event that started the call.
* @param roomName The name of the room the call is in.
* @param senderName The name of the sender of the event that started the call.
* @param avatarUrl The avatar url of the room or DM.
* @param timestamp The timestamp of the event that started the call.
* @param notificationChannelId The id of the notification channel to use for the call notification.
*/
fun handleIncomingCall(
callType: CallType.RoomCall,
eventId: EventId,
senderId: UserId,
roomName: String?,
senderName: String?,
avatarUrl: String?,
timestamp: Long,
notificationChannelId: String,
)
}

16
features/call/build.gradle.kts → features/call/impl/build.gradle.kts

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -23,11 +23,15 @@ plugins { @@ -23,11 +23,15 @@ plugins {
}
android {
namespace = "io.element.android.features.call"
namespace = "io.element.android.features.call.impl"
buildFeatures {
buildConfig = true
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
anvil {
@ -41,12 +45,17 @@ dependencies { @@ -41,12 +45,17 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.impl)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.network)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api)
implementation(projects.services.toolbox.api)
implementation(libs.androidx.webkit)
implementation(libs.coil.compose)
implementation(libs.serialization.json)
api(projects.features.call.api)
ksp(libs.showkase.processor)
testImplementation(libs.coroutines.test)
@ -54,9 +63,12 @@ dependencies { @@ -54,9 +63,12 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.mockk)
testImplementation(projects.features.call.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils)
}

24
features/call/src/main/AndroidManifest.xml → features/call/impl/src/main/AndroidManifest.xml

@ -23,11 +23,17 @@ @@ -23,11 +23,17 @@
android:name="android.hardware.microphone"
android:required="false" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Permissions for call foreground services -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<application>
<activity
@ -70,10 +76,24 @@ @@ -70,10 +76,24 @@
</intent-filter>
</activity>
<activity android:name=".ui.IncomingCallActivity"
android:configChanges="screenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:taskAffinity="io.element.android.features.call" />
<service
android:name=".CallForegroundService"
android:name=".services.CallForegroundService"
android:enabled="true"
android:foregroundServiceType="mediaPlayback" />
android:exported="false"
android:foregroundServiceType="phoneCall" />
<receiver android:name=".receivers.DeclineCallBroadcastReceiver"
android:exported="false"
android:enabled="true" />
</application>
</manifest>

69
features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt

@ -0,0 +1,69 @@ @@ -0,0 +1,69 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.IntentProvider
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultElementCallEntryPoint @Inject constructor(
@ApplicationContext private val context: Context,
private val activeCallManager: ActiveCallManager,
) : ElementCallEntryPoint {
companion object {
const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE"
const val REQUEST_CODE = 2255
}
override fun startCall(callType: CallType) {
context.startActivity(IntentProvider.createIntent(context, callType))
}
override fun handleIncomingCall(
callType: CallType.RoomCall,
eventId: EventId,
senderId: UserId,
roomName: String?,
senderName: String?,
avatarUrl: String?,
timestamp: Long,
notificationChannelId: String,
) {
val incomingCallNotificationData = CallNotificationData(
sessionId = callType.sessionId,
roomId = callType.roomId,
eventId = eventId,
senderId = senderId,
roomName = roomName,
senderName = senderName,
avatarUrl = avatarUrl,
timestamp = timestamp,
notificationChannelId = notificationChannelId,
)
activeCallManager.registerIncomingCall(notificationData = incomingCallNotificationData)
}
}

2
features/call/src/main/kotlin/io/element/android/features/call/data/WidgetMessage.kt → features/call/impl/src/main/kotlin/io/element/android/features/call/impl/data/WidgetMessage.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.data
package io.element.android.features.call.impl.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

8
features/call/src/main/kotlin/io/element/android/features/call/di/CallBindings.kt → features/call/impl/src/main/kotlin/io/element/android/features/call/impl/di/CallBindings.kt

@ -14,13 +14,17 @@ @@ -14,13 +14,17 @@
* limitations under the License.
*/
package io.element.android.features.call.di
package io.element.android.features.call.impl.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.call.ui.ElementCallActivity
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
import io.element.android.features.call.impl.ui.ElementCallActivity
import io.element.android.features.call.impl.ui.IncomingCallActivity
import io.element.android.libraries.di.AppScope
@ContributesTo(AppScope::class)
interface CallBindings {
fun inject(callActivity: ElementCallActivity)
fun inject(callActivity: IncomingCallActivity)
fun inject(declineCallBroadcastReceiver: DeclineCallBroadcastReceiver)
}

37
features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/CallNotificationData.kt

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl.notifications
import android.os.Parcelable
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.core.UserId
import kotlinx.parcelize.Parcelize
@Parcelize
data class CallNotificationData(
val sessionId: SessionId,
val roomId: RoomId,
val eventId: EventId,
val senderId: UserId,
val roomName: String?,
val senderName: String?,
val avatarUrl: String?,
val notificationChannelId: String,
val timestamp: Long,
) : Parcelable

130
features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt

@ -0,0 +1,130 @@ @@ -0,0 +1,130 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl.notifications
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.AudioManager
import android.media.RingtoneManager
import androidx.core.app.NotificationCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.Person
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
import io.element.android.features.call.impl.ui.IncomingCallActivity
import io.element.android.features.call.impl.utils.IntentProvider
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClientProvider
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.core.UserId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
/**
* Creates a notification for a ringing call.
*/
class RingingCallNotificationCreator @Inject constructor(
@ApplicationContext private val context: Context,
private val matrixClientProvider: MatrixClientProvider,
private val imageLoaderHolder: ImageLoaderHolder,
private val notificationBitmapLoader: NotificationBitmapLoader,
) {
companion object {
/**
* Request code for the decline action.
*/
const val DECLINE_REQUEST_CODE = 1
/**
* Request code for the full screen intent.
*/
const val FULL_SCREEN_INTENT_REQUEST_CODE = 2
}
suspend fun createNotification(
sessionId: SessionId,
roomId: RoomId,
eventId: EventId,
senderId: UserId,
roomName: String?,
senderDisplayName: String,
roomAvatarUrl: String?,
notificationChannelId: String,
timestamp: Long,
): Notification? {
val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
val imageLoader = imageLoaderHolder.get(matrixClient)
val largeIcon = notificationBitmapLoader.getUserIcon(roomAvatarUrl, imageLoader)
val caller = Person.Builder()
.setName(senderDisplayName)
.setIcon(largeIcon)
.setImportant(true)
.build()
val answerIntent = IntentProvider.getPendingIntent(context, CallType.RoomCall(sessionId, roomId))
val declineIntent = PendingIntentCompat.getBroadcast(
context,
DECLINE_REQUEST_CODE,
Intent(context, DeclineCallBroadcastReceiver::class.java),
PendingIntent.FLAG_CANCEL_CURRENT,
false,
)!!
val fullScreenIntent = PendingIntentCompat.getActivity(
context,
FULL_SCREEN_INTENT_REQUEST_CODE,
Intent(context, IncomingCallActivity::class.java).apply {
putExtra(
IncomingCallActivity.EXTRA_NOTIFICATION_DATA,
CallNotificationData(sessionId, roomId, eventId, senderId, roomName, senderDisplayName, roomAvatarUrl, notificationChannelId, timestamp)
)
},
PendingIntent.FLAG_CANCEL_CURRENT,
false
)
val ringtoneUri = RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE)
return NotificationCompat.Builder(context, notificationChannelId)
.setSmallIcon(CommonDrawables.ic_notification_small)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, declineIntent, answerIntent).setIsVideo(true))
.addPerson(caller)
.setAutoCancel(true)
.setWhen(timestamp)
.setOngoing(true)
.setShowWhen(false)
.setSound(ringtoneUri, AudioManager.STREAM_RING)
.setTimeoutAfter(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds.inWholeMilliseconds)
.setContentIntent(answerIntent)
.setDeleteIntent(declineIntent)
.setFullScreenIntent(fullScreenIntent, true)
.build()
.apply {
flags = flags.or(Notification.FLAG_INSISTENT)
}
}
}

37
features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.libraries.architecture.bindings
import javax.inject.Inject
/**
* Broadcast receiver to decline the incoming call.
*/
class DeclineCallBroadcastReceiver : BroadcastReceiver() {
@Inject
lateinit var activeCallManager: ActiveCallManager
override fun onReceive(context: Context, intent: Intent?) {
context.bindings<CallBindings>().inject(this)
activeCallManager.hungUpCall()
}
}

34
features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt → features/call/impl/src/main/kotlin/io/element/android/features/call/impl/services/CallForegroundService.kt

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -14,30 +14,36 @@ @@ -14,30 +14,36 @@
* limitations under the License.
*/
package io.element.android.features.call
package io.element.android.features.call.impl.services
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import io.element.android.features.call.ui.ElementCallActivity
import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.ui.ElementCallActivity
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import timber.log.Timber
/**
* A foreground service that shows a notification for an ongoing call while the UI is in background.
*/
class CallForegroundService : Service() {
companion object {
fun start(context: Context) {
val intent = Intent(context, CallForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
ContextCompat.startForegroundService(context, intent)
}
fun stop(context: Context) {
@ -69,7 +75,17 @@ class CallForegroundService : Service() { @@ -69,7 +75,17 @@ class CallForegroundService : Service() {
.setContentText(getString(R.string.call_foreground_service_message_android))
.setContentIntent(pendingIntent)
.build()
startForeground(1, notification)
val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.ONGOING_CALL)
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
} else {
0
}
runCatching {
ServiceCompat.startForeground(this, notificationId, notification, serviceType)
}.onFailure {
Timber.e(it, "Failed to start ongoing call foreground service")
}
}
override fun onDestroy() {

7
features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenEvents.kt → features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt

@ -14,11 +14,12 @@ @@ -14,11 +14,12 @@
* limitations under the License.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import io.element.android.features.call.utils.WidgetMessageInterceptor
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
sealed interface CallScreenEvents {
data object Hangup : CallScreenEvents
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) :
CallScreenEvents
}

27
features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt → features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@ -30,11 +30,12 @@ import dagger.assisted.Assisted @@ -30,11 +30,12 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.features.call.CallType
import io.element.android.features.call.data.WidgetMessage
import io.element.android.features.call.utils.CallWidgetProvider
import io.element.android.features.call.utils.WidgetMessageInterceptor
import io.element.android.features.call.utils.WidgetMessageSerializer
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.data.WidgetMessage
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.CallWidgetProvider
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
import io.element.android.features.call.impl.utils.WidgetMessageSerializer
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -65,6 +66,7 @@ class CallScreenPresenter @AssistedInject constructor( @@ -65,6 +66,7 @@ class CallScreenPresenter @AssistedInject constructor(
private val matrixClientsProvider: MatrixClientProvider,
private val screenTracker: ScreenTracker,
private val appCoroutineScope: CoroutineScope,
private val activeCallManager: ActiveCallManager,
) : Presenter<CallScreenState> {
@AssistedFactory
interface Factory {
@ -84,6 +86,10 @@ class CallScreenPresenter @AssistedInject constructor( @@ -84,6 +86,10 @@ class CallScreenPresenter @AssistedInject constructor(
LaunchedEffect(Unit) {
loadUrl(callType, urlState, callWidgetDriver)
if (callType is CallType.RoomCall) {
activeCallManager.joinedCall(callType.sessionId, callType.roomId)
}
}
when (callType) {
@ -134,6 +140,14 @@ class CallScreenPresenter @AssistedInject constructor( @@ -134,6 +140,14 @@ class CallScreenPresenter @AssistedInject constructor(
}
}
DisposableEffect(Unit) {
onDispose {
if (callType is CallType.RoomCall) {
activeCallManager.hungUpCall()
}
}
}
fun handleEvents(event: CallScreenEvents) {
when (event) {
is CallScreenEvents.Hangup -> {
@ -193,7 +207,6 @@ class CallScreenPresenter @AssistedInject constructor( @@ -193,7 +207,6 @@ class CallScreenPresenter @AssistedInject constructor(
val client = (callType as? CallType.RoomCall)?.sessionId?.let {
matrixClientsProvider.getOrNull(it)
} ?: return@DisposableEffect onDispose { }
coroutineScope.launch {
client.syncService().syncState
.onEach { state ->

2
features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt → features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import io.element.android.libraries.architecture.AsyncData

6
features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt → features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import android.annotation.SuppressLint
import android.view.ViewGroup
@ -34,8 +34,8 @@ import androidx.compose.ui.platform.LocalInspectionMode @@ -34,8 +34,8 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.call.R
import io.element.android.features.call.utils.WebViewWidgetMessageInterceptor
import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview

48
features/call/src/main/kotlin/io/element/android/features/call/ui/ElementCallActivity.kt → features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt

@ -14,12 +14,10 @@ @@ -14,12 +14,10 @@
* limitations under the License.
*/
package io.element.android.features.call.ui
package io.element.android.features.call.impl.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.res.Configuration
import android.media.AudioAttributes
import android.media.AudioFocusRequest
@ -41,30 +39,16 @@ import io.element.android.compound.theme.ElementTheme @@ -41,30 +39,16 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.theme.Theme
import io.element.android.compound.theme.isDark
import io.element.android.compound.theme.mapToTheme
import io.element.android.features.call.CallForegroundService
import io.element.android.features.call.CallType
import io.element.android.features.call.di.CallBindings
import io.element.android.features.call.utils.CallIntentDataParser
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.call.impl.utils.CallIntentDataParser
import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.architecture.bindings
import javax.inject.Inject
class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
companion object {
private const val EXTRA_CALL_WIDGET_SETTINGS = "EXTRA_CALL_WIDGET_SETTINGS"
fun start(
context: Context,
callInputs: CallType,
) {
val intent = Intent(context, ElementCallActivity::class.java).apply {
putExtra(EXTRA_CALL_WIDGET_SETTINGS, callInputs)
addFlags(FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
@Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var appPreferencesStore: AppPreferencesStore
@ -88,7 +72,13 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator { @@ -88,7 +72,13 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
applicationContext.bindings<CallBindings>().inject(this)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
setCallType(intent)
@ -157,16 +147,16 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator { @@ -157,16 +147,16 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
}
private fun setCallType(intent: Intent?) {
val inputs = intent?.let {
IntentCompat.getParcelableExtra(it, EXTRA_CALL_WIDGET_SETTINGS, CallType::class.java)
val callType = intent?.let {
IntentCompat.getParcelableExtra(it, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
}
val intentUrl = intent?.dataString?.let(::parseUrl)
when {
// Re-opened the activity but we have no url to load or a cached one, finish the activity
intent?.dataString == null && inputs == null && webViewTarget.value == null -> finish()
inputs != null -> {
webViewTarget.value = inputs
presenter = presenterFactory.create(inputs, this)
intent?.dataString == null && callType == null && webViewTarget.value == null -> finish()
callType != null -> {
webViewTarget.value = callType
presenter = presenterFactory.create(callType, this)
}
intentUrl != null -> {
val fallbackInputs = CallType.ExternalUrl(intentUrl)

96
features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt

@ -0,0 +1,96 @@ @@ -0,0 +1,96 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl.ui
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.CallState
import io.element.android.libraries.architecture.bindings
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
/**
* Activity that's displayed as a full screen intent when an incoming call is received.
*/
class IncomingCallActivity : AppCompatActivity() {
companion object {
/**
* Extra key for the notification data.
*/
const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA"
}
@Inject
lateinit var elementCallEntryPoint: ElementCallEntryPoint
@Inject
lateinit var activeCallManager: ActiveCallManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
applicationContext.bindings<CallBindings>().inject(this)
// Set flags so it can be displayed in the lock screen
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or
WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
if (notificationData != null) {
setContent {
IncomingCallScreen(
notificationData = notificationData,
onAnswer = ::onAnswer,
onCancel = ::onCancel,
)
}
} else {
// No data, finish the activity
finish()
return
}
activeCallManager.activeCall
.filter { it?.callState !is CallState.Ringing }
.onEach { finish() }
.launchIn(lifecycleScope)
}
private fun onAnswer(notificationData: CallNotificationData) {
elementCallEntryPoint.startCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
}
private fun onCancel() {
activeCallManager.hungUpCall()
}
}

192
features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt

@ -0,0 +1,192 @@ @@ -0,0 +1,192 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
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.core.UserId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun IncomingCallScreen(
notificationData: CallNotificationData,
onAnswer: (CallNotificationData) -> Unit,
onCancel: () -> Unit,
) {
ElementTheme {
OnboardingBackground()
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 124.dp)
.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Avatar(
avatarData = AvatarData(
id = notificationData.senderId.value,
name = notificationData.senderName,
url = notificationData.avatarUrl,
size = AvatarSize.IncomingCall,
)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = notificationData.senderName ?: notificationData.senderId.value,
style = ElementTheme.typography.fontHeadingMdBold,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.screen_incoming_call_subtitle_android),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, bottom = 64.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
ActionButton(
size = 64.dp,
onClick = { onAnswer(notificationData) },
icon = CompoundIcons.VoiceCall(),
title = stringResource(CommonStrings.action_accept),
backgroundColor = ElementTheme.colors.iconSuccessPrimary,
borderColor = ElementTheme.colors.borderSuccessSubtle
)
ActionButton(
size = 64.dp,
onClick = onCancel,
icon = CompoundIcons.EndCall(),
title = stringResource(CommonStrings.action_reject),
backgroundColor = ElementTheme.colors.iconCriticalPrimary,
borderColor = ElementTheme.colors.borderCriticalSubtle
)
}
}
}
}
@Composable
private fun ActionButton(
size: Dp,
onClick: () -> Unit,
icon: ImageVector,
title: String,
backgroundColor: Color,
borderColor: Color,
contentDescription: String? = title,
borderSize: Dp = 1.33.dp,
) {
Column(
modifier = Modifier.width(120.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
FilledIconButton(
modifier = Modifier.size(size + borderSize)
.border(borderSize, borderColor, CircleShape),
onClick = onClick,
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = backgroundColor,
contentColor = Color.White,
)
) {
Icon(
modifier = Modifier.size(32.dp),
imageVector = icon,
contentDescription = contentDescription
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
overflow = TextOverflow.Ellipsis,
)
}
}
@PreviewsDayNight
@Composable
internal fun IncomingCallScreenPreview() {
ElementPreview {
IncomingCallScreen(
notificationData = CallNotificationData(
sessionId = SessionId("@alice:matrix.org"),
roomId = RoomId("!1234:matrix.org"),
eventId = EventId("\$asdadadsad:matrix.org"),
senderId = UserId("@bob:matrix.org"),
roomName = "A room",
senderName = "Bob",
avatarUrl = null,
notificationChannelId = "incoming_call",
timestamp = 0L,
),
onAnswer = {},
onCancel = {},
)
}
}

206
features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt

@ -0,0 +1,206 @@ @@ -0,0 +1,206 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl.utils
import android.annotation.SuppressLint
import androidx.core.app.NotificationManagerCompat
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
/**
* Manages the active call state.
*/
interface ActiveCallManager {
/**
* The active call state flow, which will be updated when the active call changes.
*/
val activeCall: StateFlow<ActiveCall?>
/**
* Registers an incoming call if there isn't an existing active call and posts a [CallState.Ringing] notification.
* @param notificationData The data for the incoming call notification.
*/
fun registerIncomingCall(notificationData: CallNotificationData)
/**
* Called when the incoming call timed out. It will remove the active call and remove any associated UI, adding a 'missed call' notification.
*/
fun incomingCallTimedOut()
/**
* Hangs up the active call and removes any associated UI.
*/
fun hungUpCall()
/**
* Called when the user joins a call. It will remove any existing UI and set the call state as [CallState.InCall].
*
* @param sessionId The session ID of the user joining the call.
* @param roomId The room ID of the call.
*/
fun joinedCall(sessionId: SessionId, roomId: RoomId)
}
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultActiveCallManager @Inject constructor(
private val coroutineScope: CoroutineScope,
private val matrixClientProvider: MatrixClientProvider,
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
private val notificationManagerCompat: NotificationManagerCompat,
) : ActiveCallManager {
private var timedOutCallJob: Job? = null
override val activeCall = MutableStateFlow<ActiveCall?>(null)
override fun registerIncomingCall(notificationData: CallNotificationData) {
if (activeCall.value != null) {
displayMissedCallNotification(notificationData)
Timber.w("Already have an active call, ignoring incoming call: $notificationData")
return
}
activeCall.value = ActiveCall(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
callState = CallState.Ringing(notificationData),
)
timedOutCallJob = coroutineScope.launch {
showIncomingCallNotification(notificationData)
// Wait for the call to end
delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
incomingCallTimedOut()
}
}
override fun incomingCallTimedOut() {
val previousActiveCall = activeCall.value ?: return
val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return
activeCall.value = null
cancelIncomingCallNotification()
displayMissedCallNotification(notificationData)
}
override fun hungUpCall() {
cancelIncomingCallNotification()
timedOutCallJob?.cancel()
activeCall.value = null
}
override fun joinedCall(sessionId: SessionId, roomId: RoomId) {
cancelIncomingCallNotification()
timedOutCallJob?.cancel()
activeCall.value = ActiveCall(
sessionId = sessionId,
roomId = roomId,
callState = CallState.InCall,
)
// Send call notification to the room
coroutineScope.launch {
matrixClientProvider.getOrRestore(sessionId)
.getOrNull()
?.getRoom(roomId)
?.sendCallNotificationIfNeeded()
}
}
@SuppressLint("MissingPermission")
private suspend fun showIncomingCallNotification(notificationData: CallNotificationData) {
val notification = ringingCallNotificationCreator.createNotification(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
eventId = notificationData.eventId,
senderId = notificationData.senderId,
roomName = notificationData.roomName,
senderDisplayName = notificationData.senderName ?: notificationData.senderId.value,
roomAvatarUrl = notificationData.avatarUrl,
notificationChannelId = notificationData.notificationChannelId,
timestamp = notificationData.timestamp
) ?: return
runCatching {
notificationManagerCompat.notify(
NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL),
notification,
)
}.onFailure {
Timber.e(it, "Failed to publish notification for incoming call")
}
}
private fun cancelIncomingCallNotification() {
notificationManagerCompat.cancel(NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL))
}
private fun displayMissedCallNotification(notificationData: CallNotificationData) {
coroutineScope.launch {
onMissedCallNotificationHandler.addMissedCallNotification(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
eventId = notificationData.eventId,
)
}
}
}
/**
* Represents an active call.
*/
data class ActiveCall(
val sessionId: SessionId,
val roomId: RoomId,
val callState: CallState,
)
/**
* Represents the state of an active call.
*/
sealed interface CallState {
/**
* The call is in a ringing state.
* @param notificationData The data for the incoming call notification.
*/
data class Ringing(val notificationData: CallNotificationData) : CallState
/**
* The call is in an in-call state.
*/
data object InCall : CallState
}

2
features/call/src/main/kotlin/io/element/android/features/call/utils/CallIntentDataParser.kt → features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallIntentDataParser.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import android.net.Uri
import javax.inject.Inject

2
features/call/src/main/kotlin/io/element/android/features/call/utils/CallWidgetProvider.kt → features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallWidgetProvider.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId

2
features/call/src/main/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProvider.kt → features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig

42
features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/IntentProvider.kt

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.impl.utils
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.PendingIntentCompat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.ui.ElementCallActivity
internal object IntentProvider {
fun createIntent(context: Context, callType: CallType): Intent = Intent(context, ElementCallActivity::class.java).apply {
putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callType)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
}
fun getPendingIntent(context: Context, callType: CallType): PendingIntent {
return PendingIntentCompat.getActivity(
context,
DefaultElementCallEntryPoint.REQUEST_CODE,
createIntent(context, callType),
0,
false
)!!
}
}

4
features/call/src/main/kotlin/io/element/android/features/call/utils/WebViewWidgetMessageInterceptor.kt → features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import android.graphics.Bitmap
import android.webkit.JavascriptInterface
@ -22,7 +22,7 @@ import android.webkit.WebView @@ -22,7 +22,7 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import io.element.android.features.call.BuildConfig
import io.element.android.features.call.impl.BuildConfig
import kotlinx.coroutines.flow.MutableSharedFlow
class WebViewWidgetMessageInterceptor(

2
features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageInterceptor.kt → features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WidgetMessageInterceptor.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import kotlinx.coroutines.flow.Flow

4
features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageSerializer.kt → features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WidgetMessageSerializer.kt

@ -14,9 +14,9 @@ @@ -14,9 +14,9 @@
* limitations under the License.
*/
package io.element.android.features.call.utils
package io.element.android.features.call.impl.utils
import io.element.android.features.call.data.WidgetMessage
import io.element.android.features.call.impl.data.WidgetMessage
import kotlinx.serialization.json.Json
object WidgetMessageSerializer {

0
features/call/src/main/res/values-be/translations.xml → features/call/impl/src/main/res/values-be/translations.xml

0
features/call/src/main/res/values-cs/translations.xml → features/call/impl/src/main/res/values-cs/translations.xml

0
features/call/src/main/res/values-de/translations.xml → features/call/impl/src/main/res/values-de/translations.xml

0
features/call/src/main/res/values-es/translations.xml → features/call/impl/src/main/res/values-es/translations.xml

0
features/call/src/main/res/values-fr/translations.xml → features/call/impl/src/main/res/values-fr/translations.xml

0
features/call/src/main/res/values-hu/translations.xml → features/call/impl/src/main/res/values-hu/translations.xml

0
features/call/src/main/res/values-in/translations.xml → features/call/impl/src/main/res/values-in/translations.xml

0
features/call/src/main/res/values-it/translations.xml → features/call/impl/src/main/res/values-it/translations.xml

0
features/call/src/main/res/values-ka/translations.xml → features/call/impl/src/main/res/values-ka/translations.xml

0
features/call/src/main/res/values-pt/translations.xml → features/call/impl/src/main/res/values-pt/translations.xml

0
features/call/src/main/res/values-ro/translations.xml → features/call/impl/src/main/res/values-ro/translations.xml

0
features/call/src/main/res/values-ru/translations.xml → features/call/impl/src/main/res/values-ru/translations.xml

0
features/call/src/main/res/values-sk/translations.xml → features/call/impl/src/main/res/values-sk/translations.xml

0
features/call/src/main/res/values-sv/translations.xml → features/call/impl/src/main/res/values-sv/translations.xml

0
features/call/src/main/res/values-uk/translations.xml → features/call/impl/src/main/res/values-uk/translations.xml

0
features/call/src/main/res/values-zh-rTW/translations.xml → features/call/impl/src/main/res/values-zh-rTW/translations.xml

0
features/call/src/main/res/values-zh/translations.xml → features/call/impl/src/main/res/values-zh/translations.xml

0
features/call/src/main/res/values/do_not_translate.xml → features/call/impl/src/main/res/values/do_not_translate.xml

1
features/call/src/main/res/values/localazy.xml → features/call/impl/src/main/res/values/localazy.xml

@ -3,4 +3,5 @@ @@ -3,4 +3,5 @@
<string name="call_foreground_service_channel_title_android">"Ongoing call"</string>
<string name="call_foreground_service_message_android">"Tap to return to the call"</string>
<string name="call_foreground_service_title_android">"☎ Call in progress"</string>
<string name="screen_incoming_call_subtitle_android">"Incoming Element Call"</string>
</resources>

77
features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt

@ -0,0 +1,77 @@ @@ -0,0 +1,77 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call
import android.content.Intent
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.ui.ElementCallActivity
import io.element.android.features.call.utils.FakeActiveCallManager
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.tests.testutils.lambda.lambdaRecorder
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class)
class DefaultElementCallEntryPointTest {
@Test
fun `startCall - starts ElementCallActivity setup with the needed extras`() {
val entryPoint = createEntryPoint()
entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java)
val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity
assertThat(intent.component).isEqualTo(expectedIntent.component)
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue()
}
@Test
fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() {
val registerIncomingCallLambda = lambdaRecorder<CallNotificationData, Unit> {}
val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda)
val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
entryPoint.handleIncomingCall(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
roomName = "roomName",
senderName = "senderName",
avatarUrl = "avatarUrl",
timestamp = 0,
notificationChannelId = "notificationChannelId",
)
registerIncomingCallLambda.assertions().isCalledOnce()
}
private fun createEntryPoint(
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
) = DefaultElementCallEntryPoint(
context = InstrumentationRegistry.getInstrumentation().targetContext,
activeCallManager = activeCallManager,
)
}

2
features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt → features/call/impl/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt

@ -19,7 +19,7 @@ package io.element.android.features.call @@ -19,7 +19,7 @@ package io.element.android.features.call
import android.Manifest
import android.webkit.PermissionRequest
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.ui.mapWebkitPermissions
import io.element.android.features.call.impl.ui.mapWebkitPermissions
import org.junit.Test
class MapWebkitPermissionsTest {

97
features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt

@ -0,0 +1,97 @@ @@ -0,0 +1,97 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.notifications
import androidx.core.graphics.drawable.IconCompat
import androidx.test.platform.app.InstrumentationRegistry
import coil.ImageLoader
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class RingingCallNotificationCreatorTest {
@Test
fun `createNotification - with no associated MatrixClient does nothing`() = runTest {
val notificationCreator = createRingingCallNotificationCreator(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(IllegalStateException("No client found")) })
)
val result = notificationCreator.createTestNotification()
assertThat(result).isNull()
}
@Test
fun `createNotification - creates a valid notification`() = runTest {
val notificationCreator = createRingingCallNotificationCreator(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) })
)
val result = notificationCreator.createTestNotification()
assertThat(result).isNotNull()
}
@Test
fun `createNotification - tries to load the avatar URL`() = runTest {
val getUserIconLambda = lambdaRecorder<String?, ImageLoader, IconCompat?> { _, _ -> null }
val notificationCreator = createRingingCallNotificationCreator(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }),
notificationBitmapLoader = FakeNotificationBitmapLoader(getUserIconResult = getUserIconLambda)
)
notificationCreator.createTestNotification()
getUserIconLambda.assertions().isCalledOnce()
}
private suspend fun RingingCallNotificationCreator.createTestNotification() = createNotification(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
roomName = "Room",
senderDisplayName = "Johnnie Murphy",
roomAvatarUrl = "https://example.com/avatar.jpg",
notificationChannelId = "channelId",
timestamp = 0L,
)
private fun createRingingCallNotificationCreator(
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
imageLoaderHolder: FakeImageLoaderHolder = FakeImageLoaderHolder(),
notificationBitmapLoader: FakeNotificationBitmapLoader = FakeNotificationBitmapLoader(),
) = RingingCallNotificationCreator(
context = InstrumentationRegistry.getInstrumentation().targetContext,
matrixClientProvider = matrixClientProvider,
imageLoaderHolder = imageLoaderHolder,
notificationBitmapLoader = notificationBitmapLoader,
)
}

10
features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt → features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt

@ -21,7 +21,11 @@ import app.cash.molecule.moleculeFlow @@ -21,7 +21,11 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.features.call.CallType
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.ui.CallScreenEvents
import io.element.android.features.call.impl.ui.CallScreenNavigator
import io.element.android.features.call.impl.ui.CallScreenPresenter
import io.element.android.features.call.utils.FakeActiveCallManager
import io.element.android.features.call.utils.FakeCallWidgetProvider
import io.element.android.features.call.utils.FakeWidgetMessageInterceptor
import io.element.android.libraries.architecture.AsyncData
@ -254,6 +258,7 @@ class CallScreenPresenterTest { @@ -254,6 +258,7 @@ class CallScreenPresenterTest {
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
screenTracker: ScreenTracker = FakeScreenTracker(),
): CallScreenPresenter {
val userAgentProvider = object : UserAgentProvider {
@ -270,8 +275,9 @@ class CallScreenPresenterTest { @@ -270,8 +275,9 @@ class CallScreenPresenterTest {
clock = clock,
dispatchers = dispatchers,
matrixClientsProvider = matrixClientsProvider,
screenTracker = screenTracker,
appCoroutineScope = this,
activeCallManager = activeCallManager,
screenTracker = screenTracker,
)
}
}

2
features/call/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt → features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt

@ -16,6 +16,8 @@ @@ -16,6 +16,8 @@
package io.element.android.features.call.ui
import io.element.android.features.call.impl.ui.CallScreenNavigator
class FakeCallScreenNavigator : CallScreenNavigator {
var closeCalled = false
private set

1
features/call/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt → features/call/impl/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.CallIntentDataParser
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

203
features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt

@ -0,0 +1,203 @@ @@ -0,0 +1,203 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.utils
import androidx.core.app.NotificationManagerCompat
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.CallState
import io.element.android.features.call.impl.utils.DefaultActiveCallManager
import io.element.android.features.call.test.aCallNotificationData
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.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultActiveCallManagerTest {
private val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL)
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `registerIncomingCall - sets the incoming call as active`() = runTest {
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeCall.value).isNull()
val callNotificationData = aCallNotificationData()
manager.registerIncomingCall(callNotificationData)
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
callState = CallState.Ringing(callNotificationData)
)
)
runCurrent()
verify { notificationManagerCompat.notify(notificationId, any()) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `registerIncomingCall - when there is an already active call adds missed call notification`() = runTest {
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
val onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
val manager = createActiveCallManager(
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
)
// Register existing call
val callNotificationData = aCallNotificationData()
manager.registerIncomingCall(callNotificationData)
val activeCall = manager.activeCall.value
// Now add a new call
manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2))
assertThat(manager.activeCall.value).isEqualTo(activeCall)
assertThat(manager.activeCall.value?.roomId).isNotEqualTo(A_ROOM_ID_2)
advanceTimeBy(1)
addMissedCallNotificationLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID_2), value(AN_EVENT_ID))
}
@Test
fun `incomingCallTimedOut - when there isn't an active call does nothing`() = runTest {
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
val manager = createActiveCallManager(
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
)
manager.incomingCallTimedOut()
addMissedCallNotificationLambda.assertions().isNeverCalled()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest {
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
val manager = createActiveCallManager(
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda),
notificationManagerCompat = notificationManagerCompat,
)
manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull()
manager.incomingCallTimedOut()
advanceTimeBy(1)
assertThat(manager.activeCall.value).isNull()
addMissedCallNotificationLambda.assertions().isCalledOnce()
verify { notificationManagerCompat.cancel(notificationId) }
}
@Test
fun `hungUpCall - removes existing call`() = runTest {
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull()
manager.hungUpCall()
assertThat(manager.activeCall.value).isNull()
verify { notificationManagerCompat.cancel(notificationId) }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `joinedCall - register an ongoing call and tries sending the call notify event`() = runTest {
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val sendCallNotifyLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val room = FakeMatrixRoom(sendCallNotificationIfNeededResult = sendCallNotifyLambda)
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val manager = createActiveCallManager(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
notificationManagerCompat = notificationManagerCompat,
)
assertThat(manager.activeCall.value).isNull()
manager.joinedCall(A_SESSION_ID, A_ROOM_ID)
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
callState = CallState.InCall,
)
)
runCurrent()
sendCallNotifyLambda.assertions().isCalledOnce()
verify { notificationManagerCompat.cancel(notificationId) }
}
private fun TestScope.createActiveCallManager(
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
) = DefaultActiveCallManager(
coroutineScope = this,
matrixClientProvider = matrixClientProvider,
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
ringingCallNotificationCreator = RingingCallNotificationCreator(
context = InstrumentationRegistry.getInstrumentation().targetContext,
matrixClientProvider = matrixClientProvider,
imageLoaderHolder = FakeImageLoaderHolder(),
notificationBitmapLoader = FakeNotificationBitmapLoader(),
),
notificationManagerCompat = notificationManagerCompat,
)
}

1
features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt → features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider
import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider

53
features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt

@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.utils
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.MutableStateFlow
class FakeActiveCallManager(
var registerIncomingCallResult: (CallNotificationData) -> Unit = {},
var incomingCallTimedOutResult: () -> Unit = {},
var hungUpCallResult: () -> Unit = {},
var joinedCallResult: (SessionId, RoomId) -> Unit = { _, _ -> },
) : ActiveCallManager {
override val activeCall = MutableStateFlow<ActiveCall?>(null)
override fun registerIncomingCall(notificationData: CallNotificationData) {
registerIncomingCallResult(notificationData)
}
override fun incomingCallTimedOut() {
incomingCallTimedOutResult()
}
override fun hungUpCall() {
hungUpCallResult()
}
override fun joinedCall(sessionId: SessionId, roomId: RoomId) {
joinedCallResult(sessionId, roomId)
}
fun setActiveCall(value: ActiveCall?) {
this.activeCall.value = value
}
}

1
features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt → features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
package io.element.android.features.call.utils
import io.element.android.features.call.impl.utils.CallWidgetProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver

1
features/call/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt → features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
package io.element.android.features.call.utils
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
import kotlinx.coroutines.flow.MutableSharedFlow
class FakeWidgetMessageInterceptor : WidgetMessageInterceptor {

6
features/call/src/main/res/values-et/translations.xml

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Käimasolev kõne"</string>
<string name="call_foreground_service_message_android">"Kõne juurde naasmiseks klõpsa"</string>
<string name="call_foreground_service_title_android">"☎ Kõne on pooleli"</string>
</resources>

34
features/call/test/build.gradle.kts

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.call.test"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
api(projects.features.call.api)
implementation(projects.features.call.impl)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.test)
}

52
features/call/test/src/main/kotlin/io/element/android/features/call/test/CallNotificationData.kt

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.test
import io.element.android.features.call.impl.notifications.CallNotificationData
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.core.UserId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME
fun aCallNotificationData(
sessionId: SessionId = A_SESSION_ID,
roomId: RoomId = A_ROOM_ID,
eventId: EventId = AN_EVENT_ID,
senderId: UserId = A_USER_ID_2,
roomName: String = A_ROOM_NAME,
senderName: String? = A_USER_NAME,
avatarUrl: String? = AN_AVATAR_URL,
notificationChannelId: String = "channel_id",
timestamp: Long = 0L,
): CallNotificationData = CallNotificationData(
sessionId = sessionId,
roomId = roomId,
eventId = eventId,
senderId = senderId,
roomName = roomName,
senderName = senderName,
avatarUrl = avatarUrl,
notificationChannelId = notificationChannelId,
timestamp = timestamp,
)

44
features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.call.test
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
class FakeElementCallEntryPoint(
var startCallResult: (CallType) -> Unit = {},
var handleIncomingCallResult: (CallType.RoomCall, EventId, UserId, String?, String?, String?, String) -> Unit = { _, _, _, _, _, _, _ -> }
) : ElementCallEntryPoint {
override fun startCall(callType: CallType) {
startCallResult(callType)
}
override fun handleIncomingCall(
callType: CallType.RoomCall,
eventId: EventId,
senderId: UserId,
roomName: String?,
senderName: String?,
avatarUrl: String?,
timestamp: Long,
notificationChannelId: String
) {
handleIncomingCallResult(callType, eventId, senderId, roomName, senderName, avatarUrl, notificationChannelId)
}
}

9
features/createroom/impl/src/main/res/values-et/translations.xml

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Uus jututuba"</string>
<string name="screen_create_room_add_people_title">"Kutsu osalejaid"</string>
<string name="screen_create_room_private_option_description">"Sõnumid siin jututoas on krüptitud ja seda ei saa hiljem välja lülitada."</string>
<string name="screen_create_room_public_option_description">"Sõnumid pole krüptitud ja neid saavad kõik lugeda. Soovi korral saad hiljem krüptimise sisse lülitada."</string>
<string name="screen_create_room_room_name_label">"Jututoa nimi"</string>
<string name="screen_create_room_title">"Loo jututuba"</string>
</resources>

11
features/ftue/impl/src/main/res/values-et/translations.xml

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Sa võid seadistusi hiljem alati muuta."</string>
<string name="screen_notification_optin_title">"Luba teavitused ja kunagi ei jää sul sõnumid märkamata"</string>
<string name="screen_welcome_bullet_1">"Kõned, küsitlused, otsing ja palju muud lisanduvad hiljem selle aasta jooksul."</string>
<string name="screen_welcome_bullet_2">"Krüptitud jututubade sõnumite ajalugu pole veel saadaval."</string>
<string name="screen_welcome_bullet_3">"Me soovime teada mida sa arvad. Seadistuste lehel olevast valikust võid saata meile oma kommentaare."</string>
<string name="screen_welcome_button">"Alustame!"</string>
<string name="screen_welcome_subtitle">"Sa peaksid teadma alljärgnevat:"</string>
<string name="screen_welcome_title">"Tere tulemast rakendusse %1$s!"</string>
</resources>

32
features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt

@ -23,8 +23,10 @@ import io.element.android.features.invite.api.response.InviteData @@ -23,8 +23,10 @@ import io.element.android.features.invite.api.response.InviteData
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
@ -128,6 +130,12 @@ class AcceptDeclineInvitePresenterTest { @@ -128,6 +130,12 @@ class AcceptDeclineInvitePresenterTest {
@Test
fun `present - declining invite success flow`() = runTest {
val clearMembershipNotificationForRoomLambda = lambdaRecorder<SessionId, RoomId, Unit> { _, _ ->
Result.success(Unit)
}
val notificationDrawerManager = FakeNotificationDrawerManager(
clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda
)
val declineInviteSuccess = lambdaRecorder { ->
Result.success(Unit)
}
@ -139,7 +147,10 @@ class AcceptDeclineInvitePresenterTest { @@ -139,7 +147,10 @@ class AcceptDeclineInvitePresenterTest {
}
)
}
val presenter = createAcceptDeclineInvitePresenter(client = client)
val presenter = createAcceptDeclineInvitePresenter(
client = client,
notificationDrawerManager = notificationDrawerManager,
)
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
@ -159,7 +170,10 @@ class AcceptDeclineInvitePresenterTest { @@ -159,7 +170,10 @@ class AcceptDeclineInvitePresenterTest {
}
cancelAndConsumeRemainingEvents()
}
assert(declineInviteSuccess).isCalledOnce()
declineInviteSuccess.assertions().isCalledOnce()
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
}
@Test
@ -202,10 +216,19 @@ class AcceptDeclineInvitePresenterTest { @@ -202,10 +216,19 @@ class AcceptDeclineInvitePresenterTest {
@Test
fun `present - accepting invite success flow`() = runTest {
val clearMembershipNotificationForRoomLambda = lambdaRecorder<SessionId, RoomId, Unit> { _, _ ->
Result.success(Unit)
}
val notificationDrawerManager = FakeNotificationDrawerManager(
clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda
)
val joinRoomSuccess = lambdaRecorder { _: RoomId, _: List<String>, _: JoinedRoom.Trigger ->
Result.success(Unit)
}
val presenter = createAcceptDeclineInvitePresenter(joinRoomLambda = joinRoomSuccess)
val presenter = createAcceptDeclineInvitePresenter(
joinRoomLambda = joinRoomSuccess,
notificationDrawerManager = notificationDrawerManager,
)
presenter.test {
val inviteData = anInviteData()
awaitItem().also { state ->
@ -229,6 +252,9 @@ class AcceptDeclineInvitePresenterTest { @@ -229,6 +252,9 @@ class AcceptDeclineInvitePresenterTest {
value(emptyList<String>()),
value(JoinedRoom.Trigger.Invite)
)
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
}
private fun anInviteData(

2
features/leaveroom/api/src/main/res/values-be/translations.xml

@ -2,6 +2,6 @@ @@ -2,6 +2,6 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Вы ўпэўнены, што хочаце пакінуць гэту размову? Гэта размова не з\'яўляецца публічнай, і вы не зможаце далучыцца зноў без запрашэння."</string>
<string name="leave_room_alert_empty_subtitle">"Вы ўпэўнены, што хочаце пакінуць гэты пакой? Вы тут адзіны карыстальнік. Калі вы выйдзеце, ніхто не зможа далучыцца ў будучыні, у тым ліку і вы."</string>
<string name="leave_room_alert_private_subtitle">"Вы ўпэўнены, што жхочаце пакінуць гэты пакой? Гэты пакой не агульнадаступны, і вы не зможаце далучыцца да яго зноў без запрашэння."</string>
<string name="leave_room_alert_private_subtitle">"Вы ўпэўнены, што хочаце пакінуць гэты пакой? Гэты пакой не агульнадаступны, і вы не зможаце далучыцца да яго зноў без запрашэння."</string>
<string name="leave_room_alert_subtitle">"Вы ўпэўнены, што хочаце пакінуць пакой?"</string>
</resources>

7
features/leaveroom/api/src/main/res/values-et/translations.xml

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Kas sa oled kindel, et soovid sellest vestlusest lahkuda? See vestlus pole avalik ja uuesti liitumiseks vajad kutset."</string>
<string name="leave_room_alert_empty_subtitle">"Kas sa oled kindel, et soovid sellest jututoast lahkuda? Sa oled siin viimane osaleja ja peale sinu lahkumist ei saa keegi enam liituda, isegi sina mitte."</string>
<string name="leave_room_alert_private_subtitle">"Kas sa oled kindel, et soovid sellest jututoast lahkuda? See jututuba pole avalik ja uuesti liitumiseks vajad kutset."</string>
<string name="leave_room_alert_subtitle">"Kas sa oled kindel, et soovid sellest jututoast lahkuda?"</string>
</resources>

37
features/lockscreen/impl/src/main/res/values-et/translations.xml

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_app_lock_biometric_authentication">"biomeetrilist autentimist"</string>
<string name="screen_app_lock_biometric_unlock">"biomeetrilist lukustuse eemaldamist"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Eemalda lukustus biomeetrilise tuvastuse abil"</string>
<string name="screen_app_lock_forgot_pin">"Kas unustasid PIN-koodi?"</string>
<string name="screen_app_lock_settings_change_pin">"Muuda PIN-koodi"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Kasuta lukustuse eemaldamiseks biomeetrilist tuvastust"</string>
<string name="screen_app_lock_settings_remove_pin">"Eemalda PIN-kood"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Kas sa oled kindel, et soovid eemaldada PIN-koodi?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Kas eemaldame PIN-koodi?"</string>
<string name="screen_app_lock_setup_biometric_unlock_allow_title">"Kasuta %1$s"</string>
<string name="screen_app_lock_setup_biometric_unlock_skip">"Pigem kasutan PIN-koodi"</string>
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Säästa aega ja kasuta alati %1$s rakenduse lukustuse eemaldamiseks"</string>
<string name="screen_app_lock_setup_choose_pin">"Vali PIN-kood"</string>
<string name="screen_app_lock_setup_confirm_pin">"Korda PIN-koodi"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"Turvakaalutlustel sa ei saa sellist PIN-koodi kasutada"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"Kasuta mõnda teist PIN-koodi"</string>
<string name="screen_app_lock_setup_pin_context">"Lisamaks oma %1$s vestlustele turvalisust ja privaatsust, lukusta oma nutiseade.
Vali midagi, mis hästi meelde jääb. Kui unustad selle PIN-koodi, siis turvakaalutlustel logitakse sind rakendusest välja."</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Palun sisesta sama PIN-kood kaks korda"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-koodid ei klapi omavahel"</string>
<string name="screen_app_lock_signout_alert_message">"Jätkamaks pead uuesti sisse logima ja looma uue PIN-koodi"</string>
<string name="screen_app_lock_signout_alert_title">"Sa oled logimas välja"</string>
<plurals name="screen_app_lock_subtitle">
<item quantity="one">"Sul on lukustuse eemaldamiseks jäänud %1$d katse"</item>
<item quantity="other">"Sul on lukustuse eemaldamiseks jäänud %1$d katset"</item>
</plurals>
<plurals name="screen_app_lock_subtitle_wrong_pin">
<item quantity="one">"Vale PIN-kood. Saad proovida veel %1$d korra"</item>
<item quantity="other">"Vale PIN-kood. Saad proovida veel %1$d korda"</item>
</plurals>
<string name="screen_app_lock_use_biometric_android">"Kasuta biomeetrilist tuvastust"</string>
<string name="screen_app_lock_use_pin_android">"Kasuta PIN-koodi"</string>
<string name="screen_signout_in_progress_dialog_content">"Logime välja…"</string>
</resources>

2
features/login/impl/src/main/res/values-cs/translations.xml

@ -43,7 +43,7 @@ @@ -43,7 +43,7 @@
<string name="screen_qr_code_login_device_not_signed_in_scan_state_subtitle">"Druhé zařízení není přihlášeno"</string>
<string name="screen_qr_code_login_error_cancelled_subtitle">"Přihlášení bylo na druhém zařízení zrušeno."</string>
<string name="screen_qr_code_login_error_cancelled_title">"Žádost o přihlášení zrušena"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Požadavek na vašem druhém zařízení nebyl přijat."</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Přihlášení bylo na druhém zařízení odmítnuto."</string>
<string name="screen_qr_code_login_error_declined_title">"Přihlášení odmítnuto"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Platnost přihlášení vypršela. Zkuste to prosím znovu."</string>
<string name="screen_qr_code_login_error_expired_title">"Přihlášení nebylo dokončeno včas"</string>

26
features/login/impl/src/main/res/values-et/translations.xml

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_account_provider_change">"Muuda teenusepakkujat"</string>
<string name="screen_account_provider_form_hint">"Koduserveri aadress"</string>
<string name="screen_account_provider_form_notice">"Sisesta otsingusõna või domeeni nimi."</string>
<string name="screen_account_provider_form_subtitle">"Otsi äriühingut, kogukonda või võrgus leiduvat Matrixi serverit."</string>
<string name="screen_account_provider_form_title">"Leia teenusepakkuja"</string>
<string name="screen_account_provider_signin_title">"Sa oled sisse logimas %s teenusesse"</string>
<string name="screen_account_provider_signup_title">"Sa oled loomas kasutajakontot %s teenuses"</string>
<string name="screen_change_server_form_header">"Koduserveri url"</string>
<string name="screen_change_server_subtitle">"Mis on sinu koduserveri aadress?"</string>
<string name="screen_change_server_title">"Vali oma server"</string>
<string name="screen_login_error_deactivated_account">"Konto on kasutusest eemaldatud."</string>
<string name="screen_login_error_invalid_credentials">"Vigane kasutajanimi ja/või salasõna"</string>
<string name="screen_login_error_invalid_user_id">"See ei ole korrektne kasutajanimi. Õige vorming on: „@kasutaja:koduserver.ee“"</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Proovi uuesti"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Jätkamiseks pead lubama, et %1$s saab kasutada sinu nutiseadme kaamerat"</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"QR-koodi lugemiseks luba kaamerat kasutada"</string>
<string name="screen_qr_code_login_scanning_state_title">"Skaneeri QR-koodi"</string>
<string name="screen_qr_code_login_start_over_button">"Alusta uuesti"</string>
<string name="screen_qr_code_login_unknown_error_description">"Tekkis ootamatu viga. Palun proovi uuesti."</string>
<string name="screen_qr_code_login_verify_code_loading">"Ootame sinu teise seadme järgi"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Sinu teenusepakkuja võib sisselogimisel eeldada selle verifitseerimiskoodi kasutamist."</string>
<string name="screen_qr_code_login_verify_code_title">"Sinu verifitseerimiskood"</string>
<string name="screen_waitlist_message_success">"Tere tulemast rakendusse %1$s!"</string>
</resources>

13
features/login/impl/src/main/res/values-pt/translations.xml

@ -39,7 +39,20 @@ @@ -39,7 +39,20 @@
<string name="screen_qr_code_login_connection_note_secure_state_title">"Ligação insegura"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Ser-te-á pedido que insiras os dois dígitos indicados neste dispositivo."</string>
<string name="screen_qr_code_login_device_code_title">"Insere o número abaixo no teu dispositivo"</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_description">"Inicia a sessão no teu outro dispositivo e tenta novamente, ou utiliza outro dispositivo que já tenha a sessão iniciada."</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_subtitle">"O outro dispositivo não tem a sessão iniciada"</string>
<string name="screen_qr_code_login_error_cancelled_subtitle">"O início de sessão foi cancelado no outro dispositivo."</string>
<string name="screen_qr_code_login_error_cancelled_title">"Pedido de início de sessão cancelado"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"O início de sessão foi recusado no outro dispositivo."</string>
<string name="screen_qr_code_login_error_declined_title">"Início de sessão cancelado"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"O início de sessão expirou. Por favor, tenta novamente."</string>
<string name="screen_qr_code_login_error_expired_title">"O início de sessão não foi concluído a tempo"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"O teu outro dispositivo não suporta o início de sessão na %s com um código QR.
Tenta iniciar a sessão manualmente ou digitaliza o código QR com outro dispositivo."</string>
<string name="screen_qr_code_login_error_linking_not_suported_title">"Código QR não suportado"</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"O teu operador de conta não suporta %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s não suportado"</string>
<string name="screen_qr_code_login_initial_state_button_title">"Pronto para ler"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Abre a %1$s num computador"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Carrega no teu avatar"</string>

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

Loading…
Cancel
Save