Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package com.wire.android

import com.wire.android.datastore.UserDataStoreProvider
import com.wire.android.di.KaliumCoreLogic
import com.wire.android.feature.calling.AutoCallStatusObserver
import com.wire.android.notification.NotificationChannelsManager
import com.wire.android.notification.WireNotificationManager
import com.wire.android.util.CurrentScreenManager
Expand Down Expand Up @@ -61,12 +62,14 @@ class GlobalObserversManager @Inject constructor(
private val notificationChannelsManager: NotificationChannelsManager,
private val userDataStoreProvider: UserDataStoreProvider,
private val currentScreenManager: CurrentScreenManager,
private val autoCallStatusObserver: AutoCallStatusObserver,
) {
// TODO(tests): refactor so scope/dispatcher can be injected and properly stopped
private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.io())

fun observe() {
scope.launch { setUpNotifications() }
scope.launch { autoCallStatusObserver.observe() }
scope.launch {
coreLogic.getGlobalScope().observeValidAccounts().distinctUntilChanged().collectLatest {
coroutineScope {
Expand Down
46 changes: 46 additions & 0 deletions app/src/main/kotlin/com/wire/android/datastore/UserDataStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import androidx.datastore.preferences.preferencesDataStore
import com.wire.kalium.logic.data.user.UserAvailabilityStatus
import com.wire.kalium.logic.data.user.UserId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map

@Suppress("TooManyFunctions")
Expand Down Expand Up @@ -99,6 +100,43 @@ class UserDataStore(private val context: Context, userId: UserId) {
context.dataStore.edit { it[IS_CREATE_TEAM_NOTICE_READ] = isRead }
}

suspend fun saveAutoCallStatusSnapshot(
availabilityStatus: UserAvailabilityStatus,
textStatus: String?
) {
context.dataStore.edit { preferences ->
preferences[AUTO_CALL_STATUS_ACTIVE] = true
preferences[AUTO_CALL_STATUS_PREVIOUS_AVAILABILITY] = availabilityStatus.name
if (textStatus == null) {
preferences.remove(AUTO_CALL_STATUS_PREVIOUS_TEXT_STATUS)
} else {
preferences[AUTO_CALL_STATUS_PREVIOUS_TEXT_STATUS] = textStatus
}
}
}

suspend fun getAutoCallStatusSnapshot(): AutoCallStatusSnapshot? =
context.dataStore.data.map { preferences ->
if (preferences[AUTO_CALL_STATUS_ACTIVE] != true) {
null
} else {
AutoCallStatusSnapshot(
availabilityStatus = preferences[AUTO_CALL_STATUS_PREVIOUS_AVAILABILITY]
?.let(UserAvailabilityStatus::valueOf)
?: UserAvailabilityStatus.NONE,
textStatus = preferences[AUTO_CALL_STATUS_PREVIOUS_TEXT_STATUS]
)
}
}.firstOrNull()

suspend fun clearAutoCallStatusSnapshot() {
context.dataStore.edit { preferences ->
preferences.remove(AUTO_CALL_STATUS_ACTIVE)
preferences.remove(AUTO_CALL_STATUS_PREVIOUS_AVAILABILITY)
preferences.remove(AUTO_CALL_STATUS_PREVIOUS_TEXT_STATUS)
}
}

companion object {
private const val PREFERENCES_NAME = "user_data"

Expand All @@ -113,5 +151,13 @@ class UserDataStore(private val context: Context, userId: UserId) {
private val ANONYMOUS_ANALYTICS = booleanPreferencesKey("anonymous_analytics")
private val ANALYTICS_DIALOG_SEEN = booleanPreferencesKey("analytics_dialog_seen")
private val IS_CREATE_TEAM_NOTICE_READ = booleanPreferencesKey("is_create_team_notice_read")
private val AUTO_CALL_STATUS_ACTIVE = booleanPreferencesKey("auto_call_status_active")
private val AUTO_CALL_STATUS_PREVIOUS_AVAILABILITY = stringPreferencesKey("auto_call_status_previous_availability")
private val AUTO_CALL_STATUS_PREVIOUS_TEXT_STATUS = stringPreferencesKey("auto_call_status_previous_text_status")
}
}

data class AutoCallStatusSnapshot(
val availabilityStatus: UserAvailabilityStatus,
val textStatus: String?
)
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import com.wire.kalium.logic.feature.user.UpdateAccentColorUseCase
import com.wire.kalium.logic.feature.user.UpdateDisplayNameUseCase
import com.wire.kalium.logic.feature.user.UpdateEmailUseCase
import com.wire.kalium.logic.feature.user.UpdateSelfAvailabilityStatusUseCase
import com.wire.kalium.logic.feature.user.UpdateSelfTextStatusUseCase
import com.wire.kalium.logic.feature.user.UploadUserAvatarUseCase
import com.wire.kalium.logic.feature.user.UserScope
import com.wire.kalium.logic.feature.user.readReceipts.ObserveReadReceiptsEnabledUseCase
Expand Down Expand Up @@ -170,6 +171,10 @@ class UserModule {
fun provideUpdateSelfAvailabilityStatusUseCase(userScope: UserScope): UpdateSelfAvailabilityStatusUseCase =
userScope.updateSelfAvailabilityStatus

@Provides
fun provideUpdateSelfTextStatusUseCase(userScope: UserScope): UpdateSelfTextStatusUseCase =
userScope.updateSelfTextStatus

@Provides
fun provideGetAllContactsUseCase(
userScope: UserScope
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ import com.wire.android.ui.userprofile.avatarpicker.AvatarPickerViewModel
import com.wire.android.ui.userprofile.other.OtherUserProfileScreenViewModel
import com.wire.android.ui.userprofile.qr.SelfQRCodeViewModel
import com.wire.android.ui.userprofile.self.SelfUserProfileViewModel
import com.wire.android.ui.userprofile.self.status.SelfUserStatusViewModel
import com.wire.android.ui.userprofile.service.ServiceDetailsViewModelImpl
import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel
import com.wire.kalium.logic.data.id.ConversationId
Expand Down Expand Up @@ -922,6 +923,12 @@ object WireMetroViewModelBindings {
fun selfUserProfileViewModel(factory: SettingsViewModelFactory): ViewModel =
factory.selfUserProfileViewModel()

@Provides
@IntoMap
@ViewModelKey(SelfUserStatusViewModel::class)
fun selfUserStatusViewModel(factory: SettingsViewModelFactory): ViewModel =
factory.selfUserStatusViewModel()

@Provides
@IntoMap
@ViewModelKey(TeamMigrationViewModel::class)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/*
* Wire
* Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/

package com.wire.android.feature.calling

import android.content.Context
import com.wire.android.R
import com.wire.android.appLogger
import com.wire.android.datastore.UserDataStoreProvider
import com.wire.android.di.ApplicationContext
import com.wire.android.di.KaliumCoreLogic
import com.wire.android.ui.userprofile.self.status.buildTextStatus
import com.wire.kalium.common.functional.fold
import com.wire.kalium.logic.CoreLogic
import com.wire.kalium.logic.data.call.Call
import com.wire.kalium.logic.data.user.SelfUser
import com.wire.kalium.logic.data.user.UserAvailabilityStatus
import com.wire.kalium.logic.data.user.UserId
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import java.util.concurrent.ConcurrentHashMap

@SingleIn(AppScope::class)
class AutoCallStatusObserver @Inject constructor(
@ApplicationContext private val context: Context,
@KaliumCoreLogic private val coreLogic: CoreLogic,
private val userDataStoreProvider: UserDataStoreProvider,
) {

private val userMutexes = ConcurrentHashMap<UserId, Mutex>()

suspend fun observe() {
coreLogic.getGlobalScope().observeValidAccounts().collectLatest { validAccounts ->
kotlinx.coroutines.coroutineScope {
validAccounts.forEach { (selfUser, team) ->
launch {
observeAccountCallStatus(selfUser.id, team != null)
}
}
}
}
}

private suspend fun observeAccountCallStatus(userId: UserId, isTeamMember: Boolean) {
val sessionScope = coreLogic.getSessionScope(userId)
combine(
sessionScope.calls.establishedCall(),
sessionScope.users.observeSelfUserWithTeam()
) { establishedCalls, selfWithTeam ->
AccountCallStatusState(
isTeamMember = isTeamMember && selfWithTeam.second != null,
establishedCalls = establishedCalls,
selfUser = selfWithTeam.first,
)
}.collectLatest { state ->
reconcile(userId, state)
}
}

private suspend fun reconcile(userId: UserId, state: AccountCallStatusState) {

Check failure on line 81 in app/src/main/kotlin/com/wire/android/feature/calling/AutoCallStatusObserver.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 23 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-android&issues=AZ8TsmvN13LqWhcEE3i9&open=AZ8TsmvN13LqWhcEE3i9&pullRequest=5005
userMutex(userId).withLock {
val dataStore = userDataStoreProvider.getOrCreate(userId)
val snapshot = dataStore.getAutoCallStatusSnapshot()
val hasEstablishedCall = state.establishedCalls.isNotEmpty()

if (!state.isTeamMember) {
if (snapshot != null) {

Check warning on line 88 in app/src/main/kotlin/com/wire/android/feature/calling/AutoCallStatusObserver.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this "if" statement with the nested one.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-android&issues=AZ8TsmvN13LqWhcEE3i_&open=AZ8TsmvN13LqWhcEE3i_&pullRequest=5005
if (restoreSnapshot(userId, state.selfUser, snapshot.availabilityStatus, snapshot.textStatus)) {
dataStore.clearAutoCallStatusSnapshot()
}
}
return
}

if (hasEstablishedCall) {
if (snapshot != null) {
if (!isAutoCallStatusApplied(state.selfUser)) {
applyMeetingStatus(userId, state.selfUser)
}
return
}

dataStore.saveAutoCallStatusSnapshot(
availabilityStatus = state.selfUser.availabilityStatus,
textStatus = state.selfUser.textStatus
)
applyMeetingStatus(userId, state.selfUser)
return
}

if (snapshot != null) {

Check warning on line 112 in app/src/main/kotlin/com/wire/android/feature/calling/AutoCallStatusObserver.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this "if" statement with the nested one.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-android&issues=AZ8TsmvN13LqWhcEE3i-&open=AZ8TsmvN13LqWhcEE3i-&pullRequest=5005
if (restoreSnapshot(userId, state.selfUser, snapshot.availabilityStatus, snapshot.textStatus)) {
dataStore.clearAutoCallStatusSnapshot()
}
}
}
}

private suspend fun applyMeetingStatus(userId: UserId, selfUser: SelfUser): Boolean {
val statusText = buildTextStatus(
emoji = QUICK_STATUS_IN_MEETING_EMOJI,
message = context.getString(R.string.user_profile_quick_status_in_meeting)
)
val userScope = coreLogic.getSessionScope(userId).users
var success = true

if (selfUser.availabilityStatus != AUTO_CALL_AVAILABILITY) {
userScope.updateSelfAvailabilityStatus(AUTO_CALL_AVAILABILITY)
}

if (selfUser.textStatus != statusText) {
userScope.updateSelfTextStatus(statusText)
.fold(
fnL = {
success = false
appLogger.e("Failed to update auto call text status for $userId: $it")
},
fnR = {}
)
}

return success
}

private suspend fun restoreSnapshot(
userId: UserId,
selfUser: SelfUser,
availabilityStatus: UserAvailabilityStatus,
textStatus: String?
): Boolean {
val userScope = coreLogic.getSessionScope(userId).users
var success = true

if (selfUser.availabilityStatus != availabilityStatus) {
userScope.updateSelfAvailabilityStatus(availabilityStatus)
}

if (selfUser.textStatus != textStatus) {
userScope.updateSelfTextStatus(textStatus)
.fold(
fnL = {
success = false
appLogger.e("Failed to restore text status after call for $userId: $it")
},
fnR = {}
)
}

return success
}

private fun userMutex(userId: UserId): Mutex = userMutexes.getOrPut(userId) { Mutex() }

private fun isAutoCallStatusApplied(selfUser: SelfUser): Boolean =
selfUser.availabilityStatus == AUTO_CALL_AVAILABILITY &&
selfUser.textStatus == buildTextStatus(
emoji = QUICK_STATUS_IN_MEETING_EMOJI,
message = context.getString(R.string.user_profile_quick_status_in_meeting)
)

private data class AccountCallStatusState(
val isTeamMember: Boolean,
val establishedCalls: List<Call>,
val selfUser: SelfUser,
)

companion object {
private val AUTO_CALL_AVAILABILITY = UserAvailabilityStatus.BUSY
private const val QUICK_STATUS_IN_MEETING_EMOJI = "\uD83C\uDFA7"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ class MessageMapper @Inject constructor(
clientId = (message as? Message.Sendable)?.senderClientId,
accent = Accent.fromAccentId(sender?.accentId),
guestExpiresAt = sender?.expiresAt,
textStatus = sender?.textStatus,
senderId = when {
(sender as? OtherUser)?.botService != null -> MessageSenderId.Bot(sender.botService!!)
sender?.userType == UserTypeInfo.App -> MessageSenderId.App(sender.id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ fun MessageAuthorRow(
messageStyle,
modifier = Modifier.weight(weight = 1f, fill = false)
)
if (!textStatus.isNullOrBlank()) {
Text(
text = textStatus,
style = MaterialTheme.wireTypography.body02,
color = MaterialTheme.wireColorScheme.onBackground.copy(alpha = 0.6f),
modifier = Modifier.padding(start = dimensions().spacing4x),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
UserBadge(
membership = membership,
connectionState = connectionState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ data class MessageHeader(
val clientId: ClientId? = null,
val accent: Accent = Accent.Unknown,
val guestExpiresAt: Instant? = null,
val senderId: MessageSenderId? = null
val senderId: MessageSenderId? = null,
val textStatus: String? = null
)

sealed interface MessageSenderId {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import com.wire.android.ui.userprofile.avatarpicker.AvatarPickerViewModel
import com.wire.android.ui.userprofile.other.OtherUserProfileScreenViewModel
import com.wire.android.ui.userprofile.qr.SelfQRCodeViewModel
import com.wire.android.ui.userprofile.self.SelfUserProfileViewModel
import com.wire.android.ui.userprofile.self.status.SelfUserStatusViewModel
import com.wire.android.ui.userprofile.service.ServiceDetailsViewModelImpl
import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel
import com.wire.android.util.dispatchers.DispatcherProvider
Expand Down Expand Up @@ -101,6 +102,7 @@ class SettingsViewModelFactory @Inject constructor(
private val selfDevicesViewModel: Provider<SelfDevicesViewModel>,
private val avatarPickerViewModel: Provider<AvatarPickerViewModel>,
private val selfUserProfileViewModel: Provider<SelfUserProfileViewModel>,
private val selfUserStatusViewModel: Provider<SelfUserStatusViewModel>,
private val teamMigrationViewModel: Provider<TeamMigrationViewModel>,
private val updateEmail: Provider<UpdateEmailUseCase>,
private val getSelfUser: Provider<GetSelfUserUseCase>,
Expand Down Expand Up @@ -155,6 +157,7 @@ class SettingsViewModelFactory @Inject constructor(
fun selfDevicesViewModel(): SelfDevicesViewModel = selfDevicesViewModel.invoke()
fun avatarPickerViewModel(): AvatarPickerViewModel = avatarPickerViewModel.invoke()
fun selfUserProfileViewModel(): SelfUserProfileViewModel = selfUserProfileViewModel.invoke()
fun selfUserStatusViewModel(): SelfUserStatusViewModel = selfUserStatusViewModel.invoke()
fun teamMigrationViewModel(): TeamMigrationViewModel = teamMigrationViewModel.invoke()

fun verifyEmailViewModel(savedStateHandle: SavedStateHandle): VerifyEmailViewModel = VerifyEmailViewModel(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import com.wire.android.ui.userprofile.avatarpicker.AvatarPickerViewModel
import com.wire.android.ui.userprofile.other.OtherUserProfileScreenViewModel
import com.wire.android.ui.userprofile.qr.SelfQRCodeViewModel
import com.wire.android.ui.userprofile.self.SelfUserProfileViewModel
import com.wire.android.ui.userprofile.self.status.SelfUserStatusViewModel
import com.wire.android.ui.userprofile.service.ServiceDetailsViewModel
import com.wire.android.ui.userprofile.service.ServiceDetailsViewModelImpl
import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel
Expand Down Expand Up @@ -153,6 +154,10 @@ fun avatarPickerViewModel(): AvatarPickerViewModel =
fun selfUserProfileViewModel(): SelfUserProfileViewModel =
settingsViewModel()

@Composable
fun selfUserStatusViewModel(): SelfUserStatusViewModel =
settingsViewModel()

@Composable
fun selfQRCodeViewModel(): SelfQRCodeViewModel =
settingsSavedStateViewModel()
Expand Down
Loading