Merge branch 'develop' into feature/fga/message_queuing
This commit is contained in:
commit
71b6482094
620 changed files with 6821 additions and 1244 deletions
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
name: Create App Bundle (Gplay)
|
name: Create App Bundle (Gplay)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
concurrency:
|
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
|
cancel-in-progress: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
@ -43,7 +43,7 @@ jobs:
|
||||||
name: Create APKs (FDroid)
|
name: Create APKs (FDroid)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
concurrency:
|
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
|
cancel-in-progress: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
|
||||||
27
CHANGES.md
27
CHANGES.md
|
|
@ -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)
|
Changes in Element X v0.4.13 (2024-05-22)
|
||||||
=========================================
|
=========================================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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/).
|
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/).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,30 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<provider
|
||||||
android:name="androidx.startup.InitializationProvider"
|
android:name="androidx.startup.InitializationProvider"
|
||||||
android:authorities="${applicationId}.androidx-startup"
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
<locale android:name="de"/>
|
<locale android:name="de"/>
|
||||||
<locale android:name="en"/>
|
<locale android:name="en"/>
|
||||||
<locale android:name="es"/>
|
<locale android:name="es"/>
|
||||||
|
<locale android:name="et"/>
|
||||||
<locale android:name="fr"/>
|
<locale android:name="fr"/>
|
||||||
<locale android:name="hu"/>
|
<locale android:name="hu"/>
|
||||||
<locale android:name="in"/>
|
<locale android:name="in"/>
|
||||||
|
|
|
||||||
|
|
@ -17,5 +17,13 @@
|
||||||
package io.element.android.appconfig
|
package io.element.android.appconfig
|
||||||
|
|
||||||
object ElementCallConfig {
|
object ElementCallConfig {
|
||||||
|
/**
|
||||||
|
* The default base URL for the Element Call service.
|
||||||
|
*/
|
||||||
const val DEFAULT_BASE_URL = "https://call.element.io"
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ dependencies {
|
||||||
implementation(libs.coil)
|
implementation(libs.coil)
|
||||||
|
|
||||||
implementation(projects.features.ftue.api)
|
implementation(projects.features.ftue.api)
|
||||||
|
implementation(projects.features.share.api)
|
||||||
implementation(projects.features.viewfolder.api)
|
implementation(projects.features.viewfolder.api)
|
||||||
|
|
||||||
implementation(projects.services.apperror.impl)
|
implementation(projects.services.apperror.impl)
|
||||||
|
|
@ -71,6 +72,7 @@ dependencies {
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
testImplementation(projects.features.rageshake.test)
|
testImplementation(projects.features.rageshake.test)
|
||||||
testImplementation(projects.features.rageshake.impl)
|
testImplementation(projects.features.rageshake.impl)
|
||||||
|
testImplementation(projects.features.share.test)
|
||||||
testImplementation(projects.services.appnavstate.test)
|
testImplementation(projects.services.appnavstate.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(libs.test.appyx.junit)
|
testImplementation(libs.test.appyx.junit)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.appnav
|
package io.element.android.appnav
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -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.roomdirectory.api.RoomDirectoryEntryPoint
|
||||||
import io.element.android.features.roomlist.api.RoomListEntryPoint
|
import io.element.android.features.roomlist.api.RoomListEntryPoint
|
||||||
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
||||||
|
import io.element.android.features.share.api.ShareEntryPoint
|
||||||
import io.element.android.features.userprofile.api.UserProfileEntryPoint
|
import io.element.android.features.userprofile.api.UserProfileEntryPoint
|
||||||
import io.element.android.libraries.architecture.BackstackView
|
import io.element.android.libraries.architecture.BackstackView
|
||||||
import io.element.android.libraries.architecture.BaseFlowNode
|
import io.element.android.libraries.architecture.BaseFlowNode
|
||||||
|
|
@ -99,6 +101,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val networkMonitor: NetworkMonitor,
|
||||||
private val ftueService: FtueService,
|
private val ftueService: FtueService,
|
||||||
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
|
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
|
||||||
|
private val shareEntryPoint: ShareEntryPoint,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val sendingQueue: SendingQueue,
|
private val sendingQueue: SendingQueue,
|
||||||
snackbarDispatcher: SnackbarDispatcher,
|
snackbarDispatcher: SnackbarDispatcher,
|
||||||
|
|
@ -226,6 +229,9 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object RoomDirectorySearch : NavTarget
|
data object RoomDirectorySearch : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class IncomingShare(val intent: Intent) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
|
|
@ -382,6 +388,20 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
})
|
})
|
||||||
.build()
|
.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(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal suspend fun attachIncomingShare(intent: Intent) {
|
||||||
|
waitForNavTargetAttached { navTarget ->
|
||||||
|
navTarget is NavTarget.RoomList
|
||||||
|
}
|
||||||
|
attachChild<Node> {
|
||||||
|
backstack.push(
|
||||||
|
NavTarget.IncomingShare(intent)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,19 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
|
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
|
||||||
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
|
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
|
||||||
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)
|
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)
|
||||||
|
is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ sealed interface ResolvedIntent {
|
||||||
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
|
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
|
||||||
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
|
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
|
||||||
data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
|
data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
|
||||||
|
data class IncomingShare(val intent: Intent) : ResolvedIntent
|
||||||
}
|
}
|
||||||
|
|
||||||
class IntentResolver @Inject constructor(
|
class IntentResolver @Inject constructor(
|
||||||
|
|
@ -56,6 +57,10 @@ class IntentResolver @Inject constructor(
|
||||||
?.takeIf { it !is PermalinkData.FallbackLink }
|
?.takeIf { it !is PermalinkData.FallbackLink }
|
||||||
if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData)
|
if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData)
|
||||||
|
|
||||||
|
if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) {
|
||||||
|
return ResolvedIntent.IncomingShare(intent)
|
||||||
|
}
|
||||||
|
|
||||||
// Unknown intent
|
// Unknown intent
|
||||||
Timber.w("Unknown intent")
|
Timber.w("Unknown intent")
|
||||||
return null
|
return null
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue
|
||||||
import im.vector.app.features.analytics.plan.SuperProperties
|
import im.vector.app.features.analytics.plan.SuperProperties
|
||||||
import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
|
import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
|
||||||
import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter
|
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.architecture.Presenter
|
||||||
import io.element.android.libraries.matrix.api.SdkMetadata
|
import io.element.android.libraries.matrix.api.SdkMetadata
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
@ -34,6 +35,7 @@ class RootPresenter @Inject constructor(
|
||||||
private val rageshakeDetectionPresenter: RageshakeDetectionPresenter,
|
private val rageshakeDetectionPresenter: RageshakeDetectionPresenter,
|
||||||
private val appErrorStateService: AppErrorStateService,
|
private val appErrorStateService: AppErrorStateService,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
|
private val shareService: ShareService,
|
||||||
private val sdkMetadata: SdkMetadata,
|
private val sdkMetadata: SdkMetadata,
|
||||||
) : Presenter<RootState> {
|
) : Presenter<RootState> {
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -52,6 +54,10 @@ class RootPresenter @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
shareService.observeFeatureFlag(this)
|
||||||
|
}
|
||||||
|
|
||||||
return RootState(
|
return RootState(
|
||||||
rageshakeDetectionState = rageshakeDetectionState,
|
rageshakeDetectionState = rageshakeDetectionState,
|
||||||
crashDetectionState = crashDetectionState,
|
crashDetectionState = crashDetectionState,
|
||||||
|
|
|
||||||
|
|
@ -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.FakeRageShake
|
||||||
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
|
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
|
||||||
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
|
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.FakeSdkMetadata
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
|
|
@ -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.api.AppErrorStateService
|
||||||
import io.element.android.services.apperror.impl.DefaultAppErrorStateService
|
import io.element.android.services.apperror.impl.DefaultAppErrorStateService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
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 kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -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
|
@Test
|
||||||
fun `present - passes app error state`() = runTest {
|
fun `present - passes app error state`() = runTest {
|
||||||
val presenter = createRootPresenter(
|
val presenter = createRootPresenter(
|
||||||
|
|
@ -79,7 +99,8 @@ class RootPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createRootPresenter(
|
private fun createRootPresenter(
|
||||||
appErrorService: AppErrorStateService = DefaultAppErrorStateService()
|
appErrorService: AppErrorStateService = DefaultAppErrorStateService(),
|
||||||
|
shareService: ShareService = FakeShareService {},
|
||||||
): RootPresenter {
|
): RootPresenter {
|
||||||
val crashDataStore = FakeCrashDataStore()
|
val crashDataStore = FakeCrashDataStore()
|
||||||
val rageshakeDataStore = FakeRageshakeDataStore()
|
val rageshakeDataStore = FakeRageshakeDataStore()
|
||||||
|
|
@ -102,6 +123,7 @@ class RootPresenterTest {
|
||||||
rageshakeDetectionPresenter = rageshakeDetectionPresenter,
|
rageshakeDetectionPresenter = rageshakeDetectionPresenter,
|
||||||
appErrorStateService = appErrorService,
|
appErrorStateService = appErrorService,
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
|
shareService = shareService,
|
||||||
sdkMetadata = FakeSdkMetadata("sha")
|
sdkMetadata = FakeSdkMetadata("sha")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,13 +209,33 @@ class IntentResolverTest {
|
||||||
permalinkParserResult = { permalinkData }
|
permalinkParserResult = { permalinkData }
|
||||||
)
|
)
|
||||||
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
|
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
|
||||||
action = Intent.ACTION_SEND
|
action = Intent.ACTION_BATTERY_LOW
|
||||||
data = "https://matrix.to/invalid".toUri()
|
data = "https://matrix.to/invalid".toUri()
|
||||||
}
|
}
|
||||||
val result = sut.resolve(intent)
|
val result = sut.resolve(intent)
|
||||||
assertThat(result).isNull()
|
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
|
@Test
|
||||||
fun `test resolve invalid`() {
|
fun `test resolve invalid`() {
|
||||||
val sut = createIntentResolver(
|
val sut = createIntentResolver(
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ allprojects {
|
||||||
config.from(files("$rootDir/tools/detekt/detekt.yml"))
|
config.from(files("$rootDir/tools/detekt/detekt.yml"))
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
detektPlugins("io.nlopez.compose.rules:detekt:0.4.3")
|
detektPlugins("io.nlopez.compose.rules:detekt:0.4.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
// KtLint
|
// KtLint
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Render selected/deselected room list filters on top
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
BugReporting | Add public device keys to rageshakes
|
|
||||||
1
changelog.d/2894.feature
Normal file
1
changelog.d/2894.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Ringing call notifications and full screen ringing screen for DMs when the device is locked.
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Set auto captilization, multiline and autocompletion flags for the markdown EditText.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Restoree Markdown text input contents when returning to the room screen.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Move push provider setting to the "Notifications" screen and display it only when several push provider are available.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Fixed sending rich content from android keyboards on the markdown text input
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
Simplify notifications by removing the custom persistence layer.
|
|
||||||
|
|
||||||
Bump minSdk to 24 (Android 7).
|
|
||||||
|
|
@ -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 +0,0 @@
|
||||||
Be more specific with the widget permissions
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Analytics | Add support for SuperProperties
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Fix crash when restoring the selection values in the plain text editor.
|
|
||||||
|
|
@ -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
Normal file
2
fastlane/metadata/android/en-US/changelogs/40004140.txt
Normal file
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
Normal file
31
features/call/api/build.gradle.kts
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call
|
package io.element.android.features.call.api
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -23,11 +23,15 @@ plugins {
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.features.call"
|
namespace = "io.element.android.features.call.impl"
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests.isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
anvil {
|
anvil {
|
||||||
|
|
@ -41,12 +45,17 @@ dependencies {
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.matrix.impl)
|
implementation(projects.libraries.matrix.impl)
|
||||||
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.network)
|
implementation(projects.libraries.network)
|
||||||
implementation(projects.libraries.preferences.api)
|
implementation(projects.libraries.preferences.api)
|
||||||
|
implementation(projects.libraries.push.api)
|
||||||
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
implementation(projects.services.toolbox.api)
|
implementation(projects.services.toolbox.api)
|
||||||
implementation(libs.androidx.webkit)
|
implementation(libs.androidx.webkit)
|
||||||
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.serialization.json)
|
implementation(libs.serialization.json)
|
||||||
|
api(projects.features.call.api)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
||||||
testImplementation(libs.coroutines.test)
|
testImplementation(libs.coroutines.test)
|
||||||
|
|
@ -54,9 +63,12 @@ dependencies {
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
testImplementation(libs.test.robolectric)
|
testImplementation(libs.test.robolectric)
|
||||||
|
testImplementation(libs.test.mockk)
|
||||||
|
testImplementation(projects.features.call.test)
|
||||||
testImplementation(projects.libraries.featureflag.test)
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
testImplementation(projects.libraries.preferences.test)
|
testImplementation(projects.libraries.preferences.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.libraries.push.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
}
|
}
|
||||||
|
|
@ -23,11 +23,17 @@
|
||||||
android:name="android.hardware.microphone"
|
android:name="android.hardware.microphone"
|
||||||
android:required="false" />
|
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.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<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" />
|
||||||
|
<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>
|
<application>
|
||||||
<activity
|
<activity
|
||||||
|
|
@ -70,10 +76,24 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</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
|
<service
|
||||||
android:name=".CallForegroundService"
|
android:name=".services.CallForegroundService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:foregroundServiceType="mediaPlayback" />
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="phoneCall" />
|
||||||
|
|
||||||
|
<receiver android:name=".receivers.DeclineCallBroadcastReceiver"
|
||||||
|
android:exported="false"
|
||||||
|
android:enabled="true" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
@ -14,13 +14,17 @@
|
||||||
* limitations under the License.
|
* 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 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
|
import io.element.android.libraries.di.AppScope
|
||||||
|
|
||||||
@ContributesTo(AppScope::class)
|
@ContributesTo(AppScope::class)
|
||||||
interface CallBindings {
|
interface CallBindings {
|
||||||
fun inject(callActivity: ElementCallActivity)
|
fun inject(callActivity: ElementCallActivity)
|
||||||
|
fun inject(callActivity: IncomingCallActivity)
|
||||||
|
fun inject(declineCallBroadcastReceiver: DeclineCallBroadcastReceiver)
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,30 +14,36 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call
|
package io.element.android.features.call.impl.services
|
||||||
|
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.app.NotificationChannelCompat
|
import androidx.core.app.NotificationChannelCompat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.app.PendingIntentCompat
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
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.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() {
|
class CallForegroundService : Service() {
|
||||||
companion object {
|
companion object {
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
val intent = Intent(context, CallForegroundService::class.java)
|
val intent = Intent(context, CallForegroundService::class.java)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
ContextCompat.startForegroundService(context, intent)
|
||||||
context.startForegroundService(intent)
|
|
||||||
} else {
|
|
||||||
context.startService(intent)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop(context: Context) {
|
fun stop(context: Context) {
|
||||||
|
|
@ -69,7 +75,17 @@ class CallForegroundService : Service() {
|
||||||
.setContentText(getString(R.string.call_foreground_service_message_android))
|
.setContentText(getString(R.string.call_foreground_service_message_android))
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.build()
|
.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() {
|
override fun onDestroy() {
|
||||||
|
|
@ -14,11 +14,12 @@
|
||||||
* limitations under the License.
|
* 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 {
|
sealed interface CallScreenEvents {
|
||||||
data object Hangup : CallScreenEvents
|
data object Hangup : CallScreenEvents
|
||||||
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents
|
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) :
|
||||||
|
CallScreenEvents
|
||||||
}
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
|
@ -30,11 +30,12 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import im.vector.app.features.analytics.plan.MobileScreen
|
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.data.WidgetMessage
|
import io.element.android.features.call.impl.data.WidgetMessage
|
||||||
import io.element.android.features.call.utils.CallWidgetProvider
|
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||||
import io.element.android.features.call.utils.WidgetMessageInterceptor
|
import io.element.android.features.call.impl.utils.CallWidgetProvider
|
||||||
import io.element.android.features.call.utils.WidgetMessageSerializer
|
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.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||||
|
|
@ -65,6 +66,7 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
private val matrixClientsProvider: MatrixClientProvider,
|
private val matrixClientsProvider: MatrixClientProvider,
|
||||||
private val screenTracker: ScreenTracker,
|
private val screenTracker: ScreenTracker,
|
||||||
private val appCoroutineScope: CoroutineScope,
|
private val appCoroutineScope: CoroutineScope,
|
||||||
|
private val activeCallManager: ActiveCallManager,
|
||||||
) : Presenter<CallScreenState> {
|
) : Presenter<CallScreenState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -84,6 +86,10 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
loadUrl(callType, urlState, callWidgetDriver)
|
loadUrl(callType, urlState, callWidgetDriver)
|
||||||
|
|
||||||
|
if (callType is CallType.RoomCall) {
|
||||||
|
activeCallManager.joinedCall(callType.sessionId, callType.roomId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (callType) {
|
when (callType) {
|
||||||
|
|
@ -134,6 +140,14 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
if (callType is CallType.RoomCall) {
|
||||||
|
activeCallManager.hungUpCall()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvents(event: CallScreenEvents) {
|
fun handleEvents(event: CallScreenEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is CallScreenEvents.Hangup -> {
|
is CallScreenEvents.Hangup -> {
|
||||||
|
|
@ -193,7 +207,6 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
val client = (callType as? CallType.RoomCall)?.sessionId?.let {
|
val client = (callType as? CallType.RoomCall)?.sessionId?.let {
|
||||||
matrixClientsProvider.getOrNull(it)
|
matrixClientsProvider.getOrNull(it)
|
||||||
} ?: return@DisposableEffect onDispose { }
|
} ?: return@DisposableEffect onDispose { }
|
||||||
|
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
client.syncService().syncState
|
client.syncService().syncState
|
||||||
.onEach { state ->
|
.onEach { state ->
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.annotation.SuppressLint
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
|
@ -34,8 +34,8 @@ import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.call.R
|
import io.element.android.features.call.impl.R
|
||||||
import io.element.android.features.call.utils.WebViewWidgetMessageInterceptor
|
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
|
@ -14,12 +14,10 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.ui
|
package io.element.android.features.call.impl.ui
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.AudioFocusRequest
|
import android.media.AudioFocusRequest
|
||||||
|
|
@ -41,30 +39,16 @@ import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.theme.Theme
|
import io.element.android.compound.theme.Theme
|
||||||
import io.element.android.compound.theme.isDark
|
import io.element.android.compound.theme.isDark
|
||||||
import io.element.android.compound.theme.mapToTheme
|
import io.element.android.compound.theme.mapToTheme
|
||||||
import io.element.android.features.call.CallForegroundService
|
import io.element.android.features.call.api.CallType
|
||||||
import io.element.android.features.call.CallType
|
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
|
||||||
import io.element.android.features.call.di.CallBindings
|
import io.element.android.features.call.impl.di.CallBindings
|
||||||
import io.element.android.features.call.utils.CallIntentDataParser
|
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.features.preferences.api.store.AppPreferencesStore
|
||||||
import io.element.android.libraries.architecture.bindings
|
import io.element.android.libraries.architecture.bindings
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
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 callIntentDataParser: CallIntentDataParser
|
||||||
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
|
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
|
||||||
@Inject lateinit var appPreferencesStore: AppPreferencesStore
|
@Inject lateinit var appPreferencesStore: AppPreferencesStore
|
||||||
|
|
@ -88,7 +72,13 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
||||||
|
|
||||||
applicationContext.bindings<CallBindings>().inject(this)
|
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)
|
setCallType(intent)
|
||||||
|
|
||||||
|
|
@ -157,16 +147,16 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setCallType(intent: Intent?) {
|
private fun setCallType(intent: Intent?) {
|
||||||
val inputs = intent?.let {
|
val callType = intent?.let {
|
||||||
IntentCompat.getParcelableExtra(it, EXTRA_CALL_WIDGET_SETTINGS, CallType::class.java)
|
IntentCompat.getParcelableExtra(it, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
|
||||||
}
|
}
|
||||||
val intentUrl = intent?.dataString?.let(::parseUrl)
|
val intentUrl = intent?.dataString?.let(::parseUrl)
|
||||||
when {
|
when {
|
||||||
// Re-opened the activity but we have no url to load or a cached one, finish the activity
|
// 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()
|
intent?.dataString == null && callType == null && webViewTarget.value == null -> finish()
|
||||||
inputs != null -> {
|
callType != null -> {
|
||||||
webViewTarget.value = inputs
|
webViewTarget.value = callType
|
||||||
presenter = presenterFactory.create(inputs, this)
|
presenter = presenterFactory.create(callType, this)
|
||||||
}
|
}
|
||||||
intentUrl != null -> {
|
intentUrl != null -> {
|
||||||
val fallbackInputs = CallType.ExternalUrl(intentUrl)
|
val fallbackInputs = CallType.ExternalUrl(intentUrl)
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.utils
|
package io.element.android.features.call.impl.utils
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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 com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.appconfig.ElementCallConfig
|
import io.element.android.appconfig.ElementCallConfig
|
||||||
|
|
@ -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
|
||||||
|
)!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* 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.graphics.Bitmap
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
|
|
@ -22,7 +22,7 @@ import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import androidx.webkit.WebViewCompat
|
import androidx.webkit.WebViewCompat
|
||||||
import androidx.webkit.WebViewFeature
|
import androidx.webkit.WebViewFeature
|
||||||
import io.element.android.features.call.BuildConfig
|
import io.element.android.features.call.impl.BuildConfig
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
|
||||||
class WebViewWidgetMessageInterceptor(
|
class WebViewWidgetMessageInterceptor(
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.call.utils
|
package io.element.android.features.call.impl.utils
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
|
@ -14,9 +14,9 @@
|
||||||
* limitations under the License.
|
* 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
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
object WidgetMessageSerializer {
|
object WidgetMessageSerializer {
|
||||||
|
|
@ -3,4 +3,5 @@
|
||||||
<string name="call_foreground_service_channel_title_android">"Ongoing call"</string>
|
<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_message_android">"Tap to return to the call"</string>
|
||||||
<string name="call_foreground_service_title_android">"☎️ Call in progress"</string>
|
<string name="call_foreground_service_title_android">"☎️ Call in progress"</string>
|
||||||
|
<string name="screen_incoming_call_subtitle_android">"Incoming Element Call"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,7 @@ package io.element.android.features.call
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.webkit.PermissionRequest
|
import android.webkit.PermissionRequest
|
||||||
import com.google.common.truth.Truth.assertThat
|
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
|
import org.junit.Test
|
||||||
|
|
||||||
class MapWebkitPermissionsTest {
|
class MapWebkitPermissionsTest {
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,11 @@ import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import im.vector.app.features.analytics.plan.MobileScreen
|
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.FakeCallWidgetProvider
|
||||||
import io.element.android.features.call.utils.FakeWidgetMessageInterceptor
|
import io.element.android.features.call.utils.FakeWidgetMessageInterceptor
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
|
@ -254,6 +258,7 @@ class CallScreenPresenterTest {
|
||||||
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
|
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
|
||||||
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||||
matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
||||||
|
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
|
||||||
screenTracker: ScreenTracker = FakeScreenTracker(),
|
screenTracker: ScreenTracker = FakeScreenTracker(),
|
||||||
): CallScreenPresenter {
|
): CallScreenPresenter {
|
||||||
val userAgentProvider = object : UserAgentProvider {
|
val userAgentProvider = object : UserAgentProvider {
|
||||||
|
|
@ -270,8 +275,9 @@ class CallScreenPresenterTest {
|
||||||
clock = clock,
|
clock = clock,
|
||||||
dispatchers = dispatchers,
|
dispatchers = dispatchers,
|
||||||
matrixClientsProvider = matrixClientsProvider,
|
matrixClientsProvider = matrixClientsProvider,
|
||||||
screenTracker = screenTracker,
|
|
||||||
appCoroutineScope = this,
|
appCoroutineScope = this,
|
||||||
|
activeCallManager = activeCallManager,
|
||||||
|
screenTracker = screenTracker,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
|
|
||||||
package io.element.android.features.call.ui
|
package io.element.android.features.call.ui
|
||||||
|
|
||||||
|
import io.element.android.features.call.impl.ui.CallScreenNavigator
|
||||||
|
|
||||||
class FakeCallScreenNavigator : CallScreenNavigator {
|
class FakeCallScreenNavigator : CallScreenNavigator {
|
||||||
var closeCalled = false
|
var closeCalled = false
|
||||||
private set
|
private set
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.call.utils
|
package io.element.android.features.call.utils
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.call.impl.utils.CallIntentDataParser
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.features.call.utils
|
package io.element.android.features.call.utils
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
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.features.preferences.api.store.AppPreferencesStore
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
|
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.call.utils
|
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.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.call.utils
|
package io.element.android.features.call.utils
|
||||||
|
|
||||||
|
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
|
||||||
class FakeWidgetMessageInterceptor : WidgetMessageInterceptor {
|
class FakeWidgetMessageInterceptor : WidgetMessageInterceptor {
|
||||||
6
features/call/src/main/res/values-et/translations.xml
Normal file
6
features/call/src/main/res/values-et/translations.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="call_foreground_service_channel_title_android">"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
Normal file
34
features/call/test/build.gradle.kts
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
Normal file
11
features/ftue/impl/src/main/res/values-et/translations.xml
Normal file
|
|
@ -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>
|
||||||
|
|
@ -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.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
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.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_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
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.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
|
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
|
||||||
|
|
@ -128,6 +130,12 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - declining invite success flow`() = runTest {
|
fun `present - declining invite success flow`() = runTest {
|
||||||
|
val clearMembershipNotificationForRoomLambda = lambdaRecorder<SessionId, RoomId, Unit> { _, _ ->
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
val notificationDrawerManager = FakeNotificationDrawerManager(
|
||||||
|
clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda
|
||||||
|
)
|
||||||
val declineInviteSuccess = lambdaRecorder { ->
|
val declineInviteSuccess = lambdaRecorder { ->
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +147,10 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val presenter = createAcceptDeclineInvitePresenter(client = client)
|
val presenter = createAcceptDeclineInvitePresenter(
|
||||||
|
client = client,
|
||||||
|
notificationDrawerManager = notificationDrawerManager,
|
||||||
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
|
|
@ -159,7 +170,10 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
}
|
}
|
||||||
cancelAndConsumeRemainingEvents()
|
cancelAndConsumeRemainingEvents()
|
||||||
}
|
}
|
||||||
assert(declineInviteSuccess).isCalledOnce()
|
declineInviteSuccess.assertions().isCalledOnce()
|
||||||
|
clearMembershipNotificationForRoomLambda.assertions()
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -202,10 +216,19 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - accepting invite success flow`() = runTest {
|
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 ->
|
val joinRoomSuccess = lambdaRecorder { _: RoomId, _: List<String>, _: JoinedRoom.Trigger ->
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
val presenter = createAcceptDeclineInvitePresenter(joinRoomLambda = joinRoomSuccess)
|
val presenter = createAcceptDeclineInvitePresenter(
|
||||||
|
joinRoomLambda = joinRoomSuccess,
|
||||||
|
notificationDrawerManager = notificationDrawerManager,
|
||||||
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
|
|
@ -229,6 +252,9 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
value(emptyList<String>()),
|
value(emptyList<String>()),
|
||||||
value(JoinedRoom.Trigger.Invite)
|
value(JoinedRoom.Trigger.Invite)
|
||||||
)
|
)
|
||||||
|
clearMembershipNotificationForRoomLambda.assertions()
|
||||||
|
.isCalledOnce()
|
||||||
|
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun anInviteData(
|
private fun anInviteData(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,6 @@
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
<string name="leave_conversation_alert_subtitle">"Вы ўпэўнены, што хочаце пакінуць гэту размову? Гэта размова не з\'яўляецца публічнай, і вы не зможаце далучыцца зноў без запрашэння."</string>
|
<string name="leave_conversation_alert_subtitle">"Вы ўпэўнены, што хочаце пакінуць гэту размову? Гэта размова не з\'яўляецца публічнай, і вы не зможаце далучыцца зноў без запрашэння."</string>
|
||||||
<string name="leave_room_alert_empty_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>
|
<string name="leave_room_alert_subtitle">"Вы ўпэўнены, што хочаце пакінуць пакой?"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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_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_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_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_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_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>
|
<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
Normal file
26
features/login/impl/src/main/res/values-et/translations.xml
Normal file
|
|
@ -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>
|
||||||
|
|
@ -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_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_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_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_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_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_1">"Abre a %1$s num computador"</string>
|
||||||
<string name="screen_qr_code_login_initial_state_item_2">"Carrega no teu avatar"</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…
Add table
Add a link
Reference in a new issue